Notification Engine for Monasca
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

test_email_notification.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. # coding=utf-8
  2. # (C) Copyright 2014-2016 Hewlett Packard Enterprise Development LP
  3. # Copyright 2017 Fujitsu LIMITED
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
  14. # implied.
  15. # See the License for the specific language governing permissions and
  16. # limitations under the License.
  17. import base64
  18. import email.header
  19. import mock
  20. import smtplib
  21. import socket
  22. import time
  23. import six
  24. import datetime
  25. if six.PY2:
  26. import urlparse
  27. else:
  28. from urllib import parse
  29. from urllib.parse import urlparse
  30. from monasca_notification.notification import Notification
  31. from monasca_notification.plugins import email_notifier
  32. from tests import base
  33. UNICODE_CHAR = six.unichr(2344)
  34. UNICODE_CHAR_ENCODED = UNICODE_CHAR.encode("utf-8")
  35. def alarm(metrics):
  36. return {"tenantId": "0",
  37. "alarmId": "0",
  38. "alarmName": u"test Alarm " + UNICODE_CHAR,
  39. "oldState": "OK",
  40. "newState": "ALARM",
  41. "severity": "LOW",
  42. "link": "some-link",
  43. "lifecycleState": "OPEN",
  44. "stateChangeReason": u"I am alarming!" + UNICODE_CHAR,
  45. "timestamp": time.time(),
  46. "metrics": metrics}
  47. def _parse_email(email_msg):
  48. raw_mail = {"raw": email_msg}
  49. email_lines = email_msg.splitlines()
  50. from_addr, subject, to_addr = _decode_headers(email_lines)
  51. raw_mail['subject'] = subject[0].decode(subject[1])
  52. raw_mail['from'] = from_addr[0]
  53. raw_mail['to'] = to_addr[0]
  54. raw_mail['body'] = (base64.b64decode('\n'.join(email_lines[8:]))
  55. .decode('utf-8'))
  56. return raw_mail
  57. def _decode_headers(email_lines):
  58. # message is encoded, so we need to carefully go through all the lines
  59. # to pick ranges for subject, from and to
  60. keys = ['Subject', 'From', 'To']
  61. subject, from_addr, to_addr = None, None, None
  62. for key_idx, key in enumerate(keys):
  63. accummulated = []
  64. for idx in range(3, len(email_lines) - 1):
  65. line = email_lines[idx]
  66. if not line:
  67. break
  68. if key in line:
  69. accummulated.append(line)
  70. try:
  71. if keys[key_idx + 1] not in email_lines[idx + 1]:
  72. accummulated.append(email_lines[idx + 1])
  73. else:
  74. break
  75. except IndexError:
  76. pass
  77. if key == 'Subject':
  78. subject = email.header.decode_header(''.join(accummulated))[1]
  79. if key == 'From':
  80. from_addr = email.header.decode_header(''.join(accummulated))[0]
  81. if key == 'To':
  82. to_addr = email.header.decode_header(''.join(accummulated))[0]
  83. return from_addr, subject, to_addr
  84. class smtpStub(object):
  85. def __init__(self, trap):
  86. self.trap = trap
  87. def sendmail(self, from_addr, to_addr, msg):
  88. self.trap.append("{} {} {}".format(from_addr, to_addr, msg))
  89. class smtpStubException(object):
  90. def __init__(self, queue):
  91. self.queue = queue
  92. def sendmail(self, from_addr, to_addr, msg):
  93. raise smtplib.SMTPServerDisconnected
  94. class TestEmail(base.PluginTestCase):
  95. def setUp(self):
  96. super(TestEmail, self).setUp(email_notifier.register_opts)
  97. self.trap = []
  98. self.conf_override(group='email_notifier', server='my.smtp.server',
  99. port=25, user=None,
  100. password=None, timeout=60,
  101. from_addr='hpcs.mon@hp.com',
  102. grafana_url='http://127.0.0.1:3000')
  103. def _smtpStub(self, *arg, **kwargs):
  104. return smtpStub(self.trap)
  105. def _smtbStubException(self, *arg, **kwargs):
  106. return smtpStubException(self.trap)
  107. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  108. def notify(self, smtp_stub, metric, mock_smtp):
  109. mock_smtp.SMTP = smtp_stub
  110. mock_log = mock.MagicMock()
  111. mock_log.warn = self.trap.append
  112. mock_log.error = self.trap.append
  113. email = email_notifier.EmailNotifier(mock_log)
  114. alarm_dict = alarm(metric)
  115. notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict)
  116. self.trap.append(email.send_notification(notification))
  117. def test_email_notification_single_host(self):
  118. """Email with single host
  119. """
  120. metrics = []
  121. metric_data = {'name': 'cpu.percent',
  122. 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR,
  123. u'service' + UNICODE_CHAR: 'bar1'}}
  124. metrics.append(metric_data)
  125. self.notify(self._smtpStub, metrics)
  126. email = _parse_email(self.trap.pop(0))
  127. self.assertRegex(email['from'], 'hpcs.mon@hp.com')
  128. self.assertRegex(email['to'], 'me@here.com')
  129. self.assertRegex(email['raw'], 'Content-Type: text/plain')
  130. self.assertRegex(email['raw'], 'Content-Transfer-Encoding: base64')
  131. self.assertRegex(email['subject'],
  132. 'ALARM LOW "test Alarm .*" for Host: foo1.*')
  133. self.assertRegex(email['body'], 'Alarm .test Alarm.')
  134. self.assertRegex(email['body'], 'On host .foo1.')
  135. self.assertRegex(email['body'], UNICODE_CHAR)
  136. self.assertRegex(email['body'], 'Link: some-link')
  137. self.assertRegex(email['body'], 'Lifecycle state: OPEN')
  138. return_value = self.trap.pop(0)
  139. self.assertTrue(return_value)
  140. def test_email_notification_target_host(self):
  141. """Email with single host
  142. """
  143. metrics = []
  144. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR,
  145. u'service' + UNICODE_CHAR: 'bar1',
  146. u'target_host': u'some_where'}}
  147. metrics.append(metric_data)
  148. self.notify(self._smtpStub, metrics)
  149. email = _parse_email(self.trap.pop(0))
  150. self.assertRegex(email['from'], 'hpcs.mon@hp.com')
  151. self.assertRegex(email['to'], 'me@here.com')
  152. self.assertRegex(email['raw'], 'Content-Type: text/plain')
  153. self.assertRegex(email['raw'], 'Content-Transfer-Encoding: base64')
  154. self.assertRegex(email['subject'],
  155. 'ALARM LOW .test Alarm.* Target: some_where')
  156. self.assertRegex(email['body'], "Alarm .test Alarm.")
  157. self.assertRegex(email['body'], "On host .foo1.")
  158. self.assertRegex(email['body'], UNICODE_CHAR)
  159. return_value = self.trap.pop(0)
  160. self.assertTrue(return_value)
  161. def worktest_email_notification_multiple_hosts(self):
  162. """Email with multiple hosts
  163. """
  164. metrics = []
  165. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  166. metrics.append(metric_data)
  167. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  168. metrics.append(metric_data)
  169. self.notify(self._smtpStub, metrics)
  170. email = _parse_email(self.trap.pop(0))
  171. self.assertRegex(email['from'], "From: hpcs.mon@hp.com")
  172. self.assertRegex(email['to'], "To: me@here.com")
  173. self.assertRegex(email['raw'], "Content-Type: text/plain")
  174. self.assertRegex(email['subject'], "Subject: ALARM LOW .test Alarm.")
  175. self.assertRegex(email['body'], "Alarm .test Alarm.")
  176. self.assertRegex(email['body'], "foo1")
  177. self.assertRegex(email['body'], "foo2")
  178. self.assertRegex(email['body'], "bar1")
  179. self.assertRegex(email['body'], "bar2")
  180. return_value = self.trap.pop(0)
  181. self.assertTrue(return_value)
  182. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  183. def test_smtp_sendmail_failed_connection_twice(self, mock_smtp):
  184. """Email that fails on smtp_connect twice
  185. """
  186. metrics = []
  187. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  188. metrics.append(metric_data)
  189. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  190. metrics.append(metric_data)
  191. mock_log = mock.MagicMock()
  192. mock_log.warn = self.trap.append
  193. mock_log.error = self.trap.append
  194. mock_log.debug = self.trap.append
  195. mock_log.info = self.trap.append
  196. mock_log.exception = self.trap.append
  197. # mock_smtp.SMTP.return_value = mock_smtp
  198. mock_smtp.SMTP.side_effect = [mock_smtp,
  199. smtplib.SMTPServerDisconnected,
  200. socket.error]
  201. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected,
  202. smtplib.SMTPServerDisconnected]
  203. # There has to be a better way to preserve exception definitions when
  204. # we're mocking access to a library
  205. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  206. mock_smtp.SMTPException = smtplib.SMTPException
  207. email = email_notifier.EmailNotifier(mock_log)
  208. email.config()
  209. alarm_dict = alarm(metrics)
  210. notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict)
  211. self.trap.append(email.send_notification(notification))
  212. self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  213. self.assertIn("Unable to connect to email server.", self.trap)
  214. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  215. def test_smtp_sendmail_smtp_None(self, mock_smtp):
  216. """Email that fails on smtp_connect twice
  217. """
  218. metrics = []
  219. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  220. metrics.append(metric_data)
  221. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  222. metrics.append(metric_data)
  223. mock_log = mock.MagicMock()
  224. mock_log.warn = self.trap.append
  225. mock_log.error = self.trap.append
  226. mock_log.debug = self.trap.append
  227. mock_log.info = self.trap.append
  228. mock_log.exception = self.trap.append
  229. mock_smtp.SMTP.return_value = None
  230. mock_smtp.SMTP.side_effect = [socket.error,
  231. socket.error,
  232. socket.error]
  233. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected,
  234. smtplib.SMTPServerDisconnected]
  235. # There has to be a better way to preserve exception definitions when
  236. # we're mocking access to a library
  237. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  238. mock_smtp.SMTPException = smtplib.SMTPException
  239. email = email_notifier.EmailNotifier(mock_log)
  240. email.config()
  241. del self.trap[:]
  242. alarm_dict = alarm(metrics)
  243. notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict)
  244. email_result = email.send_notification(notification)
  245. self.assertFalse(email_result)
  246. self.assertIn("Connecting to Email Server my.smtp.server",
  247. self.trap)
  248. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  249. def test_smtp_sendmail_failed_connection_once_then_email(self, mock_smtp):
  250. """Email that fails on smtp_connect once then email
  251. """
  252. metrics = []
  253. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  254. metrics.append(metric_data)
  255. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  256. metrics.append(metric_data)
  257. mock_log = mock.MagicMock()
  258. mock_log.warn = self.trap.append
  259. mock_log.error = self.trap.append
  260. mock_log.debug = self.trap.append
  261. mock_log.info = self.trap.append
  262. mock_log.exception = self.trap.append
  263. mock_smtp.SMTP.return_value = mock_smtp
  264. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected,
  265. smtplib.SMTPException]
  266. # There has to be a better way to preserve exception definitions when
  267. # we're mocking access to a library
  268. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  269. mock_smtp.SMTPException = smtplib.SMTPException
  270. email = email_notifier.EmailNotifier(mock_log)
  271. email.config()
  272. alarm_dict = alarm(metrics)
  273. notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict)
  274. self.trap.append(email.send_notification(notification))
  275. self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  276. self.assertIn("Error sending Email Notification", self.trap)
  277. self.assertNotIn("Unable to connect to email server.", self.trap)
  278. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  279. def test_smtp_sendmail_failed_connection_once(self, mock_smtp):
  280. """Email that fails on smtp_connect once
  281. """
  282. metrics = []
  283. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  284. metrics.append(metric_data)
  285. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  286. metrics.append(metric_data)
  287. mock_log = mock.MagicMock()
  288. mock_log.warn = self.trap.append
  289. mock_log.error = self.trap.append
  290. mock_log.debug = self.trap.append
  291. mock_log.info = self.trap.append
  292. mock_log.exception = self.trap.append
  293. mock_smtp.SMTP.return_value = mock_smtp
  294. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected, None]
  295. # There has to be a better way to preserve exception definitions when
  296. # we're mocking access to a library
  297. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  298. mock_smtp.SMTPException = smtplib.SMTPException
  299. email = email_notifier.EmailNotifier(mock_log)
  300. email.config()
  301. alarm_dict = alarm(metrics)
  302. notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict)
  303. self.trap.append(email.send_notification(notification))
  304. self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  305. self.assertIn("Sent email to %s, notification %s"
  306. % (notification.address, notification.to_json()), self.trap)
  307. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  308. def test_smtp_sendmail_failed_exception(self, mock_smtp):
  309. """Email that fails on exception
  310. """
  311. metrics = []
  312. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  313. metrics.append(metric_data)
  314. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  315. metrics.append(metric_data)
  316. mock_log = mock.MagicMock()
  317. mock_log.warn = self.trap.append
  318. mock_log.error = self.trap.append
  319. mock_log.debug = self.trap.append
  320. mock_log.info = self.trap.append
  321. mock_log.exception = self.trap.append
  322. mock_smtp.SMTP.return_value = mock_smtp
  323. mock_smtp.sendmail.side_effect = smtplib.SMTPException
  324. # There has to be a better way to preserve exception definitions when
  325. # we're mocking access to a library
  326. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  327. mock_smtp.SMTPException = smtplib.SMTPException
  328. email = email_notifier.EmailNotifier(mock_log)
  329. email.config()
  330. alarm_dict = alarm(metrics)
  331. notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict)
  332. self.trap.append(email.send_notification(notification))
  333. self.assertNotIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  334. self.assertIn("Error sending Email Notification", self.trap)
  335. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  336. def test_get_link_url(self, mock_smtp):
  337. # Given one metric with name and dimensions
  338. metrics = []
  339. metric = {'name': 'cpu.percent',
  340. 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  341. metrics.append(metric)
  342. mock_log = mock.MagicMock()
  343. mock_log.warn = self.trap.append
  344. mock_log.error = self.trap.append
  345. mock_log.debug = self.trap.append
  346. mock_log.info = self.trap.append
  347. mock_log.exception = self.trap.append
  348. mock_smtp.SMTP.return_value = mock_smtp
  349. mock_smtp.sendmail.side_effect = smtplib.SMTPException
  350. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  351. mock_smtp.SMTPException = smtplib.SMTPException
  352. email = email_notifier.EmailNotifier(mock_log)
  353. # Create alarm timestamp and timestamp for 'from' and 'to' dates in milliseconds.
  354. alarm_date = datetime.datetime(2017, 6, 7, 18, 0)
  355. alarm_ms, expected_from_ms, expected_to_ms = self.create_time_data(alarm_date)
  356. # When retrieving the link to Grafana for the first metric and given timestamp
  357. result_url = email._get_link_url(metrics[0], alarm_ms)
  358. self.assertIsNotNone(result_url)
  359. # Then the following link to Grafana (including the metric info and timestamp) is expected.
  360. expected_url = "http://127.0.0.1:3000/dashboard/script/drilldown.js" \
  361. "?metric=cpu.percent&dim_hostname=foo1&dim_service=bar1" \
  362. "&from=%s&to=%s" % (expected_from_ms, expected_to_ms)
  363. self._assert_equal_urls(expected_url, result_url)
  364. def create_time_data(self, alarm_date):
  365. epoch = datetime.datetime.utcfromtimestamp(0)
  366. alarm_ms = int(round((alarm_date - epoch).total_seconds() * 1000))
  367. # From and to dates are 10 minutes before and after the alarm occurred.
  368. from_date = alarm_date - datetime.timedelta(minutes=10)
  369. to_date = alarm_date + datetime.timedelta(minutes=10)
  370. expected_from_ms = int(round((from_date - epoch).total_seconds() * 1000))
  371. expected_to_ms = int(round((to_date - epoch).total_seconds() * 1000))
  372. return alarm_ms, expected_from_ms, expected_to_ms
  373. def _assert_equal_urls(self, expected_url, result_url):
  374. if six.PY2:
  375. expected_parsed = urlparse.urlparse(expected_url)
  376. result_parsed = urlparse.urlparse(result_url)
  377. else:
  378. expected_parsed = urlparse(expected_url)
  379. result_parsed = urlparse(result_url)
  380. self.assertEqual(expected_parsed.netloc, result_parsed.netloc)
  381. self.assertEqual(expected_parsed.path, result_parsed.path)
  382. if six.PY2:
  383. expected_parsed_query = urlparse.parse_qs(expected_parsed.query)
  384. result_parsed_query = urlparse.parse_qs(result_parsed.query)
  385. else:
  386. expected_parsed_query = parse.parse_qs(expected_parsed.query)
  387. result_parsed_query = parse.parse_qs(result_parsed.query)
  388. self.assertEqual(len(expected_parsed_query), len(result_parsed_query))
  389. for key in six.iterkeys(result_parsed_query):
  390. self.assertEqual(expected_parsed_query[key], result_parsed_query[key])