Browse Source

Merge "Fix slack notification"

Jenkins 1 year ago
parent
commit
20479d1c1d
2 changed files with 404 additions and 43 deletions
  1. 105
    43
      monasca_notification/plugins/slack_notifier.py
  2. 299
    0
      tests/test_slack_notification.py

+ 105
- 43
monasca_notification/plugins/slack_notifier.py View File

@@ -19,18 +19,38 @@ import ujson as json
19 19
 
20 20
 from 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:
26
-        1. Login to your slack account via browser and check the following pages
27
-             a. https://api.slack.com/docs/oauth-test-tokens
28
-             b. https://api.slack.com/tokens
23
+class SlackNotifier(abstract_notifier.AbstractNotifier):
24
+    """This module is a notification plugin to integrate with Slack.
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
 
33
-class SlackNotifier(abstract_notifier.AbstractNotifier):
34 54
     def __init__(self, log):
35 55
         self._log = log
36 56
 
@@ -65,6 +85,43 @@ class SlackNotifier(abstract_notifier.AbstractNotifier):
65 85
 
66 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 125
     def send_notification(self, notification):
69 126
         """Send the notification via slack
70 127
             Posts on the given url
@@ -73,9 +130,9 @@ class SlackNotifier(abstract_notifier.AbstractNotifier):
73 130
         slack_message = self._build_slack_message(notification)
74 131
 
75 132
         address = notification.address
76
-        #  "#" is reserved character and replace it with ascii equivalent
77
-        #  Slack room has "#" as first character
78
-        address = address.replace("#", "%23")
133
+        #  '#' is reserved character and replace it with ascii equivalent
134
+        #  Slack room has '#' as first character
135
+        address = address.replace('#', '%23')
79 136
 
80 137
         parsed_url = urllib.parse.urlsplit(address)
81 138
         query_params = urllib.parse.parse_qs(parsed_url.query)
@@ -83,39 +140,44 @@ class SlackNotifier(abstract_notifier.AbstractNotifier):
83 140
         url = urllib.parse.urljoin(address, urllib.parse.urlparse(address).path)
84 141
 
85 142
         # Default option is to do cert verification
86
-        verify = not self._config.get('insecure', True)
87 143
         # If ca_certs is specified, do cert validation and ignore insecure flag
88
-        if (self._config.get("ca_certs")):
89
-            verify = self._config.get("ca_certs")
144
+        verify = self._config.get(self.CONFIG_CA_CERTS,
145
+                                  (not self._config.get(self.CONFIG_INSECURE, True)))
90 146
 
91 147
         proxyDict = None
92
-        if (self._config.get("proxy")):
93
-            proxyDict = {"https": self._config.get("proxy")}
94
-
95
-        try:
96
-            # Posting on the given URL
97
-            self._log.debug("Sending to the url {0} , with query_params {1}".format(url, query_params))
98
-            result = requests.post(url=url,
99
-                                   json=slack_message,
100
-                                   verify=verify,
101
-                                   params=query_params,
102
-                                   proxies=proxyDict,
103
-                                   timeout=self._config['timeout'])
104
-
105
-            if result.status_code not in range(200, 300):
106
-                self._log.error("Received an HTTP code {} when trying to post on URL {}."
107
-                                .format(result.status_code, url))
108
-                return False
109
-
110
-            # Slack returns 200 ok even if the token is invalid. Response has valid error message
111
-            response = json.loads(result.text)
112
-            if response.get('ok'):
113
-                self._log.info("Notification successfully posted.")
148
+        if (self.CONFIG_PROXY in self._config):
149
+            proxyDict = {'https': self._config.get(self.CONFIG_PROXY)}
150
+
151
+        data_format_list = ['json', 'data']
152
+        if url in SlackNotifier._raw_data_url_caches:
153
+            data_format_list = ['data']
154
+
155
+        for data_format in data_format_list:
156
+            self._log.info('Trying to send message to {} as {}'
157
+                           .format(url, data_format))
158
+            request_options = {
159
+                'url': url,
160
+                'verify': verify,
161
+                'params': query_params,
162
+                'proxies': proxyDict,
163
+                'timeout': self._config[self.CONFIG_TIMEOUT],
164
+                data_format: slack_message
165
+            }
166
+            if self._send_message(request_options):
167
+                if (data_format == 'data' and
168
+                        url not in SlackNotifier._raw_data_url_caches and
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 179
                 return True
115
-            else:
116
-                self._log.error("Received an error message {} when trying to send to slack on URL {}."
117
-                                .format(response.get("error"), url))
118
-                return False
119
-        except Exception:
120
-            self._log.exception("Error trying to send to slack  on URL {}".format(url))
121
-            return False
180
+
181
+            self._log.info('Failed to send message to {} as {}'
182
+                           .format(url, data_format))
183
+        return False

+ 299
- 0
tests/test_slack_notification.py View File

@@ -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
+
14
+import json
15
+import mock
16
+
17
+from oslotest import base
18
+
19
+import six
20
+
21
+from monasca_notification import notification as m_notification
22
+from monasca_notification.plugins import slack_notifier
23
+
24
+if six.PY2:
25
+    import Queue as queue
26
+else:
27
+    import queue
28
+
29
+
30
+def 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
+
46
+def 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
+
63
+class 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
+
73
+class 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'))

Loading…
Cancel
Save