diff --git a/monasca_notification/plugins/slack_notifier.py b/monasca_notification/plugins/slack_notifier.py index 1a8183b..4b281f4 100644 --- a/monasca_notification/plugins/slack_notifier.py +++ b/monasca_notification/plugins/slack_notifier.py @@ -19,18 +19,38 @@ import ujson as json from monasca_notification.plugins import abstract_notifier -""" - notification.address = https://slack.com/api/chat.postMessage?token=token&channel=#channel" - - Slack documentation about tokens: - 1. Login to your slack account via browser and check the following pages - a. https://api.slack.com/docs/oauth-test-tokens - b. https://api.slack.com/tokens - -""" - class SlackNotifier(abstract_notifier.AbstractNotifier): + """This module is a notification plugin to integrate with Slack. + + This plugin supports 2 types of APIs below. + + 1st: Slack API + notification.address = https://slack.com/api/chat.postMessage?token={token}&channel=#foobar + + You need to specify your token and channel name in the address. + Regarding {token}, login to your slack account via browser and check the following page. + https://api.slack.com/docs/oauth-test-tokens + + 2nd: Incoming webhook + notification.address = https://hooks.slack.com/services/foo/bar/buz + + You need to get the Incoming webhook URL. + Login to your slack account via browser and check the following page. + https://my.slack.com/services/new/incoming-webhook/ + Slack document about incoming webhook: + https://api.slack.com/incoming-webhooks + """ + + CONFIG_CA_CERTS = 'ca_certs' + CONFIG_INSECURE = 'insecure' + CONFIG_PROXY = 'proxy' + CONFIG_TIMEOUT = 'timeout' + MAX_CACHE_SIZE = 100 + RESPONSE_OK = 'ok' + + _raw_data_url_caches = [] + def __init__(self, log): self._log = log @@ -65,6 +85,43 @@ class SlackNotifier(abstract_notifier.AbstractNotifier): return slack_request + def _check_response(self, result): + if 'application/json' in result.headers.get('Content-Type'): + response = result.json() + if response.get(self.RESPONSE_OK): + return True + else: + self._log.error('Received an error message when trying to send to slack. error={}' + .format(response.get('error'))) + return False + elif self.RESPONSE_OK == result.text: + return True + else: + self._log.error('Received an error message when trying to send to slack. error={}' + .format(result.text)) + return False + + def _send_message(self, request_options): + try: + url = request_options.get('url') + result = requests.post(**request_options) + if result.status_code not in range(200, 300): + self._log.error('Received an HTTP code {} when trying to post on URL {}.' + .format(result.status_code, url)) + return False + + # Slack returns 200 ok even if the token is invalid. Response has valid error message + if self._check_response(result): + self._log.info('Notification successfully posted.') + return True + + self._log.error('Failed to send to slack on URL {}.'.format(url)) + return False + except Exception as err: + self._log.exception('Error trying to send to slack on URL {}. Detail: {}' + .format(url, err)) + return False + def send_notification(self, notification): """Send the notification via slack Posts on the given url @@ -73,9 +130,9 @@ class SlackNotifier(abstract_notifier.AbstractNotifier): slack_message = self._build_slack_message(notification) address = notification.address - # "#" is reserved character and replace it with ascii equivalent - # Slack room has "#" as first character - address = address.replace("#", "%23") + # '#' is reserved character and replace it with ascii equivalent + # Slack room has '#' as first character + address = address.replace('#', '%23') parsed_url = urllib.parse.urlsplit(address) query_params = urllib.parse.parse_qs(parsed_url.query) @@ -83,39 +140,44 @@ class SlackNotifier(abstract_notifier.AbstractNotifier): url = urllib.parse.urljoin(address, urllib.parse.urlparse(address).path) # Default option is to do cert verification - verify = not self._config.get('insecure', True) # If ca_certs is specified, do cert validation and ignore insecure flag - if (self._config.get("ca_certs")): - verify = self._config.get("ca_certs") + verify = self._config.get(self.CONFIG_CA_CERTS, + (not self._config.get(self.CONFIG_INSECURE, True))) proxyDict = None - if (self._config.get("proxy")): - proxyDict = {"https": self._config.get("proxy")} + if (self.CONFIG_PROXY in self._config): + proxyDict = {'https': self._config.get(self.CONFIG_PROXY)} - try: - # Posting on the given URL - self._log.debug("Sending to the url {0} , with query_params {1}".format(url, query_params)) - result = requests.post(url=url, - json=slack_message, - verify=verify, - params=query_params, - proxies=proxyDict, - timeout=self._config['timeout']) + data_format_list = ['json', 'data'] + if url in SlackNotifier._raw_data_url_caches: + data_format_list = ['data'] - if result.status_code not in range(200, 300): - self._log.error("Received an HTTP code {} when trying to post on URL {}." - .format(result.status_code, url)) - return False - - # Slack returns 200 ok even if the token is invalid. Response has valid error message - response = json.loads(result.text) - if response.get('ok'): - self._log.info("Notification successfully posted.") + for data_format in data_format_list: + self._log.info('Trying to send message to {} as {}' + .format(url, data_format)) + request_options = { + 'url': url, + 'verify': verify, + 'params': query_params, + 'proxies': proxyDict, + 'timeout': self._config[self.CONFIG_TIMEOUT], + data_format: slack_message + } + if self._send_message(request_options): + if (data_format == 'data' and + url not in SlackNotifier._raw_data_url_caches and + len(SlackNotifier._raw_data_url_caches) < self.MAX_CACHE_SIZE): + # NOTE: + # There are a few URLs which can accept only raw data, so + # only the URLs with raw data are kept in the cache. When + # too many URLs exists, it can be considered malicious + # user registers them. + # In this case, older ones should be safer than newer + # ones. When exceeding the cache size, do not replace the + # the old cache with the newer one. + SlackNotifier._raw_data_url_caches.append(url) return True - else: - self._log.error("Received an error message {} when trying to send to slack on URL {}." - .format(response.get("error"), url)) - return False - except Exception: - self._log.exception("Error trying to send to slack on URL {}".format(url)) - return False + + self._log.info('Failed to send message to {} as {}' + .format(url, data_format)) + return False diff --git a/tests/test_slack_notification.py b/tests/test_slack_notification.py new file mode 100644 index 0000000..880ab44 --- /dev/null +++ b/tests/test_slack_notification.py @@ -0,0 +1,299 @@ +# 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 json +import mock + +from oslotest import base + +import six + +from monasca_notification import notification as m_notification +from monasca_notification.plugins import slack_notifier + +if six.PY2: + import Queue as queue +else: + import queue + + +def alarm(metrics): + return {'tenantId': '0', + 'alarmId': '0', + 'alarmDefinitionId': 0, + 'alarmName': 'test Alarm', + 'alarmDescription': 'test Alarm description', + 'oldState': 'OK', + 'newState': 'ALARM', + 'severity': 'CRITICAL', + 'link': 'some-link', + 'lifecycleState': 'OPEN', + 'stateChangeReason': 'I am alarming!', + 'timestamp': 1429023453632, + 'metrics': metrics} + + +def slack_text(): + return {'old_state': 'OK', + 'alarm_description': 'test Alarm description', + 'message': 'I am alarming!', + 'alarm_definition_id': 0, + 'alarm_name': 'test Alarm', + 'tenant_id': '0', + 'metrics': [ + {'dimensions': { + 'hostname': 'foo1', + 'service': 'bar1'}} + ], + 'alarm_id': '0', + 'state': 'ALARM', + 'alarm_timestamp': 1429023453} + + +class RequestsResponse(object): + def __init__(self, status, text, headers): + self.status_code = status + self.text = text + self.headers = headers + + def json(self): + return json.loads(self.text) + + +class TestSlack(base.BaseTestCase): + def setUp(self): + super(TestSlack, self).setUp() + + self._trap = queue.Queue() + + mock_log = mock.Mock() + mock_log.info = self._trap.put + mock_log.warn = self._trap.put + mock_log.error = self._trap.put + mock_log.exception = self._trap.put + + self._slk = slack_notifier.SlackNotifier(mock_log) + slack_notifier.SlackNotifier._raw_data_url_caches = [] + + self._slack_config = {'timeout': 50, + 'ca_certs': '/etc/ssl/certs/ca-bundle.crt', + 'proxy': 'http://yourid:password@proxyserver:8080', + 'insecure': False} + + @mock.patch('monasca_notification.plugins.slack_notifier.requests') + def _notify(self, response_list, slack_config, mock_requests): + mock_requests.post = mock.Mock(side_effect=response_list) + + self._slk.config(slack_config) + + metric = [] + metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric.append(metric_data) + + alarm_dict = alarm(metric) + + notification = m_notification.Notification(0, 'slack', 'slack notification', + 'http://test.slack:3333', 0, 0, + alarm_dict) + + return mock_requests.post, self._slk.send_notification(notification) + + def _validate_post_args(self, post_args, data_format): + self.assertEqual(slack_text(), + json.loads(post_args.get(data_format).get('text'))) + self.assertEqual({'https': 'http://yourid:password@proxyserver:8080'}, + post_args.get('proxies')) + self.assertEqual(50, post_args.get('timeout')) + self.assertEqual('http://test.slack:3333', post_args.get('url')) + self.assertEqual('/etc/ssl/certs/ca-bundle.crt', + post_args.get('verify')) + + def test_slack_webhook_success(self): + """slack success + """ + response_list = [RequestsResponse(200, 'ok', + {'Content-Type': 'application/text'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertTrue(result) + mock_method.assert_called_once() + self._validate_post_args(mock_method.call_args_list[0][1], 'json') + self.assertEqual([], slack_notifier.SlackNotifier._raw_data_url_caches) + + def test_slack_webhook_fail(self): + """data is sent twice as json and raw data, and slack returns failure for + both requests + """ + response_list = [RequestsResponse(200, 'failure', + {'Content-Type': 'application/text'}), + RequestsResponse(200, '{"ok":false,"error":"failure"}', + {'Content-Type': 'application/json'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertFalse(result) + self._validate_post_args(mock_method.call_args_list[0][1], 'json') + self._validate_post_args(mock_method.call_args_list[1][1], 'data') + self.assertEqual([], slack_notifier.SlackNotifier._raw_data_url_caches) + + def test_slack_post_message_success_no_cache(self): + """data is sent as json at first and get error, second it's sent as raw data + """ + response_list = [RequestsResponse(200, '{"ok":false,"error":"failure"}', + {'Content-Type': 'application/json'}), + RequestsResponse(200, '{"ok":true}', + {'Content-Type': 'application/json'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertTrue(result) + self._validate_post_args(mock_method.call_args_list[0][1], 'json') + self._validate_post_args(mock_method.call_args_list[1][1], 'data') + self.assertEqual(['http://test.slack:3333'], + slack_notifier.SlackNotifier._raw_data_url_caches) + + def test_slack_post_message_success_cached(self): + """url in cache and data is sent as raw data at first time + """ + with mock.patch.object(slack_notifier.SlackNotifier, + '_raw_data_url_caches', + ['http://test.slack:3333']): + response_list = [RequestsResponse(200, '{"ok":true}', + {'Content-Type': 'application/json'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertTrue(result) + mock_method.assert_called_once() + self._validate_post_args(mock_method.call_args_list[0][1], 'data') + self.assertEqual(['http://test.slack:3333'], + slack_notifier.SlackNotifier._raw_data_url_caches) + + def test_slack_post_message_failed_cached(self): + """url in cache and slack returns failure + """ + with mock.patch.object(slack_notifier.SlackNotifier, + '_raw_data_url_caches', + ['http://test.slack:3333']): + response_list = [RequestsResponse(200, '{"ok":false,"error":"failure"}', + {'Content-Type': 'application/json'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertFalse(result) + mock_method.assert_called_once() + self._validate_post_args(mock_method.call_args_list[0][1], 'data') + self.assertEqual(['http://test.slack:3333'], + slack_notifier.SlackNotifier._raw_data_url_caches) + + def test_slack_webhook_success_only_timeout(self): + """slack success with only timeout config + """ + response_list = [RequestsResponse(200, 'ok', + {'Content-Type': 'application/text'})] + mock_method, result = self._notify(response_list, {'timeout': 50}) + self.assertTrue(result) + mock_method.assert_called_once() + self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, []) + + post_args = mock_method.call_args_list[0][1] + self.assertEqual(slack_text(), + json.loads(post_args.get('json').get('text'))) + self.assertEqual(None, post_args.get('proxies')) + self.assertEqual(50, post_args.get('timeout')) + self.assertEqual('http://test.slack:3333', post_args.get('url')) + self.assertFalse(post_args.get('verify')) + + def test_slack_exception(self): + """exception occurs + """ + mock_method, result = self._notify(RuntimeError('exception'), + self._slack_config) + self.assertFalse(result) + + self._validate_post_args(mock_method.call_args_list[0][1], 'json') + self._validate_post_args(mock_method.call_args_list[1][1], 'data') + + def test_slack_reponse_400(self): + """slack returns 400 error + """ + response_list = [RequestsResponse(400, '{"ok":false,"error":"failure"}', + {'Content-Type': 'application/json'}), + RequestsResponse(400, '{"ok":false,"error":"failure"}', + {'Content-Type': 'application/json'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertFalse(result) + + self._validate_post_args(mock_method.call_args_list[0][1], 'json') + self._validate_post_args(mock_method.call_args_list[1][1], 'data') + + def test_slack_post_message_success_cache_full(self): + """url in cache and data is sent as raw data at first time + """ + dummy_cache = [d for d in range(0, 100)] + with mock.patch.object(slack_notifier.SlackNotifier, + '_raw_data_url_caches', + dummy_cache): + response_list = [RequestsResponse(200, '{"ok":false,"error":"failure"}', + {'Content-Type': 'application/json'}), + RequestsResponse(200, '{"ok":true}', + {'Content-Type': 'application/json'})] + mock_method, result = self._notify(response_list, self._slack_config) + self.assertTrue(result) + self._validate_post_args(mock_method.call_args_list[0][1], 'json') + self._validate_post_args(mock_method.call_args_list[1][1], 'data') + self.assertEqual(dummy_cache, + slack_notifier.SlackNotifier._raw_data_url_caches) + + def test_config_insecure_true_ca_certs(self): + slack_config = {'timeout': 50, + 'ca_certs': '/etc/ssl/certs/ca-bundle.crt', + 'insecure': True} + response_list = [RequestsResponse(200, 'ok', + {'Content-Type': 'application/text'})] + + mock_method, result = self._notify(response_list, slack_config) + self.assertTrue(result) + mock_method.assert_called_once() + self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, []) + post_args = mock_method.call_args_list[0][1] + self.assertEqual(slack_text(), + json.loads(post_args.get('json').get('text'))) + self.assertEqual(50, post_args.get('timeout')) + self.assertEqual('http://test.slack:3333', post_args.get('url')) + self.assertEqual('/etc/ssl/certs/ca-bundle.crt', post_args.get('verify')) + + def test_config_insecure_true_no_ca_certs(self): + slack_config = {'timeout': 50, + 'insecure': True} + response_list = [RequestsResponse(200, 'ok', + {'Content-Type': 'application/text'})] + + mock_method, result = self._notify(response_list, slack_config) + self.assertTrue(result) + mock_method.assert_called_once() + self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, []) + post_args = mock_method.call_args_list[0][1] + self.assertEqual(slack_text(), + json.loads(post_args.get('json').get('text'))) + self.assertEqual(50, post_args.get('timeout')) + self.assertEqual('http://test.slack:3333', post_args.get('url')) + self.assertFalse(post_args.get('verify')) + + def test_config_insecure_false_no_ca_certs(self): + slack_config = {'timeout': 50, + 'insecure': False} + response_list = [RequestsResponse(200, 'ok', + {'Content-Type': 'application/text'})] + + mock_method, result = self._notify(response_list, slack_config) + self.assertTrue(result) + mock_method.assert_called_once() + self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, []) + post_args = mock_method.call_args_list[0][1] + self.assertEqual(slack_text(), + json.loads(post_args.get('json').get('text'))) + self.assertEqual(50, post_args.get('timeout')) + self.assertEqual('http://test.slack:3333', post_args.get('url')) + self.assertTrue(post_args.get('verify'))