summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJenkins <jenkins@review.openstack.org>2017-08-08 03:55:57 +0000
committerGerrit Code Review <review@openstack.org>2017-08-08 03:55:57 +0000
commit20479d1c1d88153722d91b3d4d228647940b4665 (patch)
treeeb4df2e44c030cd9e83e6032df87a47a23416cbe
parent68a6a8319bbc22e15c06733e62c5d30669521f48 (diff)
parent4cfcf0803f246eda020f5e89e21b80e1db778084 (diff)
Merge "Fix slack notification"1.10.0
-rw-r--r--monasca_notification/plugins/slack_notifier.py148
-rw-r--r--tests/test_slack_notification.py299
2 files changed, 404 insertions, 43 deletions
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
19 19
20from monasca_notification.plugins import abstract_notifier 20from monasca_notification.plugins import abstract_notifier
21 21
22"""
23 notification.address = https://slack.com/api/chat.postMessage?token=token&channel=#channel"
24 22
25 Slack documentation about tokens: 23class SlackNotifier(abstract_notifier.AbstractNotifier):
26 1. Login to your slack account via browser and check the following pages 24 """This module is a notification plugin to integrate with Slack.
27 a. https://api.slack.com/docs/oauth-test-tokens
28 b. https://api.slack.com/tokens
29 25
30""" 26 This plugin supports 2 types of APIs below.
31 27
28 1st: Slack API
29 notification.address = https://slack.com/api/chat.postMessage?token={token}&channel=#foobar
30
31 You need to specify your token and channel name in the address.
32 Regarding {token}, login to your slack account via browser and check the following page.
33 https://api.slack.com/docs/oauth-test-tokens
34
35 2nd: Incoming webhook
36 notification.address = https://hooks.slack.com/services/foo/bar/buz
37
38 You need to get the Incoming webhook URL.
39 Login to your slack account via browser and check the following page.
40 https://my.slack.com/services/new/incoming-webhook/
41 Slack document about incoming webhook:
42 https://api.slack.com/incoming-webhooks
43 """
44
45 CONFIG_CA_CERTS = 'ca_certs'
46 CONFIG_INSECURE = 'insecure'
47 CONFIG_PROXY = 'proxy'
48 CONFIG_TIMEOUT = 'timeout'
49 MAX_CACHE_SIZE = 100
50 RESPONSE_OK = 'ok'
51
52 _raw_data_url_caches = []
32 53
33class SlackNotifier(abstract_notifier.AbstractNotifier):
34 def __init__(self, log): 54 def __init__(self, log):
35 self._log = log 55 self._log = log
36 56
@@ -65,6 +85,43 @@ class SlackNotifier(abstract_notifier.AbstractNotifier):
65 85
66 return slack_request 86 return slack_request
67 87
88 def _check_response(self, result):
89 if 'application/json' in result.headers.get('Content-Type'):
90 response = result.json()
91 if response.get(self.RESPONSE_OK):
92 return True
93 else:
94 self._log.error('Received an error message when trying to send to slack. error={}'
95 .format(response.get('error')))
96 return False
97 elif self.RESPONSE_OK == result.text:
98 return True
99 else:
100 self._log.error('Received an error message when trying to send to slack. error={}'
101 .format(result.text))
102 return False
103
104 def _send_message(self, request_options):
105 try:
106 url = request_options.get('url')
107 result = requests.post(**request_options)
108 if result.status_code not in range(200, 300):
109 self._log.error('Received an HTTP code {} when trying to post on URL {}.'
110 .format(result.status_code, url))
111 return False
112
113 # Slack returns 200 ok even if the token is invalid. Response has valid error message
114 if self._check_response(result):
115 self._log.info('Notification successfully posted.')
116 return True
117
118 self._log.error('Failed to send to slack on URL {}.'.format(url))
119 return False
120 except Exception as err:
121 self._log.exception('Error trying to send to slack on URL {}. Detail: {}'
122 .format(url, err))
123 return False
124
68 def send_notification(self, notification): 125 def send_notification(self, notification):
69 """Send the notification via slack 126 """Send the notification via slack
70 Posts on the given url 127 Posts on the given url
@@ -73,9 +130,9 @@ class SlackNotifier(abstract_notifier.AbstractNotifier):
73 slack_message = self._build_slack_message(notification) 130 slack_message = self._build_slack_message(notification)
74 131
75 address = notification.address 132 address = notification.address
76 # "#" is reserved character and replace it with ascii equivalent 133 # '#' is reserved character and replace it with ascii equivalent
77 # Slack room has "#" as first character 134 # Slack room has '#' as first character
78 address = address.replace("#", "%23") 135 address = address.replace('#', '%23')
79 136
80 parsed_url = urllib.parse.urlsplit(address) 137 parsed_url = urllib.parse.urlsplit(address)
81 query_params = urllib.parse.parse_qs(parsed_url.query) 138 query_params = urllib.parse.parse_qs(parsed_url.query)
@@ -83,39 +140,44 @@ class SlackNotifier(abstract_notifier.AbstractNotifier):
83 url = urllib.parse.urljoin(address, urllib.parse.urlparse(address).path) 140 url = urllib.parse.urljoin(address, urllib.parse.urlparse(address).path)
84 141
85 # Default option is to do cert verification 142 # Default option is to do cert verification
86 verify = not self._config.get('insecure', True)
87 # If ca_certs is specified, do cert validation and ignore insecure flag 143 # If ca_certs is specified, do cert validation and ignore insecure flag
88 if (self._config.get("ca_certs")): 144 verify = self._config.get(self.CONFIG_CA_CERTS,
89 verify = self._config.get("ca_certs") 145 (not self._config.get(self.CONFIG_INSECURE, True)))
90 146
91 proxyDict = None 147 proxyDict = None
92 if (self._config.get("proxy")): 148 if (self.CONFIG_PROXY in self._config):
93 proxyDict = {"https": self._config.get("proxy")} 149 proxyDict = {'https': self._config.get(self.CONFIG_PROXY)}
94 150
95 try: 151 data_format_list = ['json', 'data']
96 # Posting on the given URL 152 if url in SlackNotifier._raw_data_url_caches:
97 self._log.debug("Sending to the url {0} , with query_params {1}".format(url, query_params)) 153 data_format_list = ['data']
98 result = requests.post(url=url, 154
99 json=slack_message, 155 for data_format in data_format_list:
100 verify=verify, 156 self._log.info('Trying to send message to {} as {}'
101 params=query_params, 157 .format(url, data_format))
102 proxies=proxyDict, 158 request_options = {
103 timeout=self._config['timeout']) 159 'url': url,
104 160 'verify': verify,
105 if result.status_code not in range(200, 300): 161 'params': query_params,
106 self._log.error("Received an HTTP code {} when trying to post on URL {}." 162 'proxies': proxyDict,
107 .format(result.status_code, url)) 163 'timeout': self._config[self.CONFIG_TIMEOUT],
108 return False 164 data_format: slack_message
109 165 }
110 # Slack returns 200 ok even if the token is invalid. Response has valid error message 166 if self._send_message(request_options):
111 response = json.loads(result.text) 167 if (data_format == 'data' and
112 if response.get('ok'): 168 url not in SlackNotifier._raw_data_url_caches and
113 self._log.info("Notification successfully posted.") 169 len(SlackNotifier._raw_data_url_caches) < self.MAX_CACHE_SIZE):
170 # NOTE:
171 # There are a few URLs which can accept only raw data, so
172 # only the URLs with raw data are kept in the cache. When
173 # too many URLs exists, it can be considered malicious
174 # user registers them.
175 # In this case, older ones should be safer than newer
176 # ones. When exceeding the cache size, do not replace the
177 # the old cache with the newer one.
178 SlackNotifier._raw_data_url_caches.append(url)
114 return True 179 return True
115 else: 180
116 self._log.error("Received an error message {} when trying to send to slack on URL {}." 181 self._log.info('Failed to send message to {} as {}'
117 .format(response.get("error"), url)) 182 .format(url, data_format))
118 return False 183 return False
119 except Exception:
120 self._log.exception("Error trying to send to slack on URL {}".format(url))
121 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 @@
1# Licensed under the Apache License, Version 2.0 (the "License");
2# you may not use this file except in compliance with the License.
3# You may obtain a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS,
9# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
10# implied.
11# See the License for the specific language governing permissions and
12# limitations under the License.
13
14import json
15import mock
16
17from oslotest import base
18
19import six
20
21from monasca_notification import notification as m_notification
22from monasca_notification.plugins import slack_notifier
23
24if six.PY2:
25 import Queue as queue
26else:
27 import queue
28
29
30def alarm(metrics):
31 return {'tenantId': '0',
32 'alarmId': '0',
33 'alarmDefinitionId': 0,
34 'alarmName': 'test Alarm',
35 'alarmDescription': 'test Alarm description',
36 'oldState': 'OK',
37 'newState': 'ALARM',
38 'severity': 'CRITICAL',
39 'link': 'some-link',
40 'lifecycleState': 'OPEN',
41 'stateChangeReason': 'I am alarming!',
42 'timestamp': 1429023453632,
43 'metrics': metrics}
44
45
46def slack_text():
47 return {'old_state': 'OK',
48 'alarm_description': 'test Alarm description',
49 'message': 'I am alarming!',
50 'alarm_definition_id': 0,
51 'alarm_name': 'test Alarm',
52 'tenant_id': '0',
53 'metrics': [
54 {'dimensions': {
55 'hostname': 'foo1',
56 'service': 'bar1'}}
57 ],
58 'alarm_id': '0',
59 'state': 'ALARM',
60 'alarm_timestamp': 1429023453}
61
62
63class RequestsResponse(object):
64 def __init__(self, status, text, headers):
65 self.status_code = status
66 self.text = text
67 self.headers = headers
68
69 def json(self):
70 return json.loads(self.text)
71
72
73class TestSlack(base.BaseTestCase):
74 def setUp(self):
75 super(TestSlack, self).setUp()
76
77 self._trap = queue.Queue()
78
79 mock_log = mock.Mock()
80 mock_log.info = self._trap.put
81 mock_log.warn = self._trap.put
82 mock_log.error = self._trap.put
83 mock_log.exception = self._trap.put
84
85 self._slk = slack_notifier.SlackNotifier(mock_log)
86 slack_notifier.SlackNotifier._raw_data_url_caches = []
87
88 self._slack_config = {'timeout': 50,
89 'ca_certs': '/etc/ssl/certs/ca-bundle.crt',
90 'proxy': 'http://yourid:password@proxyserver:8080',
91 'insecure': False}
92
93 @mock.patch('monasca_notification.plugins.slack_notifier.requests')
94 def _notify(self, response_list, slack_config, mock_requests):
95 mock_requests.post = mock.Mock(side_effect=response_list)
96
97 self._slk.config(slack_config)
98
99 metric = []
100 metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
101 metric.append(metric_data)
102
103 alarm_dict = alarm(metric)
104
105 notification = m_notification.Notification(0, 'slack', 'slack notification',
106 'http://test.slack:3333', 0, 0,
107 alarm_dict)
108
109 return mock_requests.post, self._slk.send_notification(notification)
110
111 def _validate_post_args(self, post_args, data_format):
112 self.assertEqual(slack_text(),
113 json.loads(post_args.get(data_format).get('text')))
114 self.assertEqual({'https': 'http://yourid:password@proxyserver:8080'},
115 post_args.get('proxies'))
116 self.assertEqual(50, post_args.get('timeout'))
117 self.assertEqual('http://test.slack:3333', post_args.get('url'))
118 self.assertEqual('/etc/ssl/certs/ca-bundle.crt',
119 post_args.get('verify'))
120
121 def test_slack_webhook_success(self):
122 """slack success
123 """
124 response_list = [RequestsResponse(200, 'ok',
125 {'Content-Type': 'application/text'})]
126 mock_method, result = self._notify(response_list, self._slack_config)
127 self.assertTrue(result)
128 mock_method.assert_called_once()
129 self._validate_post_args(mock_method.call_args_list[0][1], 'json')
130 self.assertEqual([], slack_notifier.SlackNotifier._raw_data_url_caches)
131
132 def test_slack_webhook_fail(self):
133 """data is sent twice as json and raw data, and slack returns failure for
134 both requests
135 """
136 response_list = [RequestsResponse(200, 'failure',
137 {'Content-Type': 'application/text'}),
138 RequestsResponse(200, '{"ok":false,"error":"failure"}',
139 {'Content-Type': 'application/json'})]
140 mock_method, result = self._notify(response_list, self._slack_config)
141 self.assertFalse(result)
142 self._validate_post_args(mock_method.call_args_list[0][1], 'json')
143 self._validate_post_args(mock_method.call_args_list[1][1], 'data')
144 self.assertEqual([], slack_notifier.SlackNotifier._raw_data_url_caches)
145
146 def test_slack_post_message_success_no_cache(self):
147 """data is sent as json at first and get error, second it's sent as raw data
148 """
149 response_list = [RequestsResponse(200, '{"ok":false,"error":"failure"}',
150 {'Content-Type': 'application/json'}),
151 RequestsResponse(200, '{"ok":true}',
152 {'Content-Type': 'application/json'})]
153 mock_method, result = self._notify(response_list, self._slack_config)
154 self.assertTrue(result)
155 self._validate_post_args(mock_method.call_args_list[0][1], 'json')
156 self._validate_post_args(mock_method.call_args_list[1][1], 'data')
157 self.assertEqual(['http://test.slack:3333'],
158 slack_notifier.SlackNotifier._raw_data_url_caches)
159
160 def test_slack_post_message_success_cached(self):
161 """url in cache and data is sent as raw data at first time
162 """
163 with mock.patch.object(slack_notifier.SlackNotifier,
164 '_raw_data_url_caches',
165 ['http://test.slack:3333']):
166 response_list = [RequestsResponse(200, '{"ok":true}',
167 {'Content-Type': 'application/json'})]
168 mock_method, result = self._notify(response_list, self._slack_config)
169 self.assertTrue(result)
170 mock_method.assert_called_once()
171 self._validate_post_args(mock_method.call_args_list[0][1], 'data')
172 self.assertEqual(['http://test.slack:3333'],
173 slack_notifier.SlackNotifier._raw_data_url_caches)
174
175 def test_slack_post_message_failed_cached(self):
176 """url in cache and slack returns failure
177 """
178 with mock.patch.object(slack_notifier.SlackNotifier,
179 '_raw_data_url_caches',
180 ['http://test.slack:3333']):
181 response_list = [RequestsResponse(200, '{"ok":false,"error":"failure"}',
182 {'Content-Type': 'application/json'})]
183 mock_method, result = self._notify(response_list, self._slack_config)
184 self.assertFalse(result)
185 mock_method.assert_called_once()
186 self._validate_post_args(mock_method.call_args_list[0][1], 'data')
187 self.assertEqual(['http://test.slack:3333'],
188 slack_notifier.SlackNotifier._raw_data_url_caches)
189
190 def test_slack_webhook_success_only_timeout(self):
191 """slack success with only timeout config
192 """
193 response_list = [RequestsResponse(200, 'ok',
194 {'Content-Type': 'application/text'})]
195 mock_method, result = self._notify(response_list, {'timeout': 50})
196 self.assertTrue(result)
197 mock_method.assert_called_once()
198 self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, [])
199
200 post_args = mock_method.call_args_list[0][1]
201 self.assertEqual(slack_text(),
202 json.loads(post_args.get('json').get('text')))
203 self.assertEqual(None, post_args.get('proxies'))
204 self.assertEqual(50, post_args.get('timeout'))
205 self.assertEqual('http://test.slack:3333', post_args.get('url'))
206 self.assertFalse(post_args.get('verify'))
207
208 def test_slack_exception(self):
209 """exception occurs
210 """
211 mock_method, result = self._notify(RuntimeError('exception'),
212 self._slack_config)
213 self.assertFalse(result)
214
215 self._validate_post_args(mock_method.call_args_list[0][1], 'json')
216 self._validate_post_args(mock_method.call_args_list[1][1], 'data')
217
218 def test_slack_reponse_400(self):
219 """slack returns 400 error
220 """
221 response_list = [RequestsResponse(400, '{"ok":false,"error":"failure"}',
222 {'Content-Type': 'application/json'}),
223 RequestsResponse(400, '{"ok":false,"error":"failure"}',
224 {'Content-Type': 'application/json'})]
225 mock_method, result = self._notify(response_list, self._slack_config)
226 self.assertFalse(result)
227
228 self._validate_post_args(mock_method.call_args_list[0][1], 'json')
229 self._validate_post_args(mock_method.call_args_list[1][1], 'data')
230
231 def test_slack_post_message_success_cache_full(self):
232 """url in cache and data is sent as raw data at first time
233 """
234 dummy_cache = [d for d in range(0, 100)]
235 with mock.patch.object(slack_notifier.SlackNotifier,
236 '_raw_data_url_caches',
237 dummy_cache):
238 response_list = [RequestsResponse(200, '{"ok":false,"error":"failure"}',
239 {'Content-Type': 'application/json'}),
240 RequestsResponse(200, '{"ok":true}',
241 {'Content-Type': 'application/json'})]
242 mock_method, result = self._notify(response_list, self._slack_config)
243 self.assertTrue(result)
244 self._validate_post_args(mock_method.call_args_list[0][1], 'json')
245 self._validate_post_args(mock_method.call_args_list[1][1], 'data')
246 self.assertEqual(dummy_cache,
247 slack_notifier.SlackNotifier._raw_data_url_caches)
248
249 def test_config_insecure_true_ca_certs(self):
250 slack_config = {'timeout': 50,
251 'ca_certs': '/etc/ssl/certs/ca-bundle.crt',
252 'insecure': True}
253 response_list = [RequestsResponse(200, 'ok',
254 {'Content-Type': 'application/text'})]
255
256 mock_method, result = self._notify(response_list, slack_config)
257 self.assertTrue(result)
258 mock_method.assert_called_once()
259 self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, [])
260 post_args = mock_method.call_args_list[0][1]
261 self.assertEqual(slack_text(),
262 json.loads(post_args.get('json').get('text')))
263 self.assertEqual(50, post_args.get('timeout'))
264 self.assertEqual('http://test.slack:3333', post_args.get('url'))
265 self.assertEqual('/etc/ssl/certs/ca-bundle.crt', post_args.get('verify'))
266
267 def test_config_insecure_true_no_ca_certs(self):
268 slack_config = {'timeout': 50,
269 'insecure': True}
270 response_list = [RequestsResponse(200, 'ok',
271 {'Content-Type': 'application/text'})]
272
273 mock_method, result = self._notify(response_list, slack_config)
274 self.assertTrue(result)
275 mock_method.assert_called_once()
276 self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, [])
277 post_args = mock_method.call_args_list[0][1]
278 self.assertEqual(slack_text(),
279 json.loads(post_args.get('json').get('text')))
280 self.assertEqual(50, post_args.get('timeout'))
281 self.assertEqual('http://test.slack:3333', post_args.get('url'))
282 self.assertFalse(post_args.get('verify'))
283
284 def test_config_insecure_false_no_ca_certs(self):
285 slack_config = {'timeout': 50,
286 'insecure': False}
287 response_list = [RequestsResponse(200, 'ok',
288 {'Content-Type': 'application/text'})]
289
290 mock_method, result = self._notify(response_list, slack_config)
291 self.assertTrue(result)
292 mock_method.assert_called_once()
293 self.assertEqual(slack_notifier.SlackNotifier._raw_data_url_caches, [])
294 post_args = mock_method.call_args_list[0][1]
295 self.assertEqual(slack_text(),
296 json.loads(post_args.get('json').get('text')))
297 self.assertEqual(50, post_args.get('timeout'))
298 self.assertEqual('http://test.slack:3333', post_args.get('url'))
299 self.assertTrue(post_args.get('verify'))