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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  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',
  116. 'me@here.com', 0, 0, alarm_dict)
  117. self.trap.append(email.send_notification(notification))
  118. def test_email_notification_single_host(self):
  119. """Email with single host
  120. """
  121. metrics = []
  122. metric_data = {'name': 'cpu.percent',
  123. 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR,
  124. u'service' + UNICODE_CHAR: 'bar1'}}
  125. metrics.append(metric_data)
  126. self.notify(self._smtpStub, metrics)
  127. email = _parse_email(self.trap.pop(0))
  128. self.assertRegex(email['from'], 'hpcs.mon@hp.com')
  129. self.assertRegex(email['to'], 'me@here.com')
  130. self.assertRegex(email['raw'], 'Content-Type: text/plain')
  131. self.assertRegex(email['raw'], 'Content-Transfer-Encoding: base64')
  132. self.assertRegex(email['subject'],
  133. 'ALARM LOW "test Alarm .*" for Host: foo1.*')
  134. self.assertRegex(email['body'], 'Alarm .test Alarm.')
  135. self.assertRegex(email['body'], 'On host .foo1.')
  136. self.assertRegex(email['body'], UNICODE_CHAR)
  137. self.assertRegex(email['body'], 'Link: some-link')
  138. self.assertRegex(email['body'], 'Lifecycle state: OPEN')
  139. return_value = self.trap.pop(0)
  140. self.assertTrue(return_value)
  141. def test_email_notification_target_host(self):
  142. """Email with single host
  143. """
  144. metrics = []
  145. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR,
  146. u'service' + UNICODE_CHAR: 'bar1',
  147. u'target_host': u'some_where'}}
  148. metrics.append(metric_data)
  149. self.notify(self._smtpStub, metrics)
  150. email = _parse_email(self.trap.pop(0))
  151. self.assertRegex(email['from'], 'hpcs.mon@hp.com')
  152. self.assertRegex(email['to'], 'me@here.com')
  153. self.assertRegex(email['raw'], 'Content-Type: text/plain')
  154. self.assertRegex(email['raw'], 'Content-Transfer-Encoding: base64')
  155. self.assertRegex(email['subject'],
  156. 'ALARM LOW .test Alarm.* Target: some_where')
  157. self.assertRegex(email['body'], "Alarm .test Alarm.")
  158. self.assertRegex(email['body'], "On host .foo1.")
  159. self.assertRegex(email['body'], UNICODE_CHAR)
  160. return_value = self.trap.pop(0)
  161. self.assertTrue(return_value)
  162. def worktest_email_notification_multiple_hosts(self):
  163. """Email with multiple hosts
  164. """
  165. metrics = []
  166. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  167. metrics.append(metric_data)
  168. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  169. metrics.append(metric_data)
  170. self.notify(self._smtpStub, metrics)
  171. email = _parse_email(self.trap.pop(0))
  172. self.assertRegex(email['from'], "From: hpcs.mon@hp.com")
  173. self.assertRegex(email['to'], "To: me@here.com")
  174. self.assertRegex(email['raw'], "Content-Type: text/plain")
  175. self.assertRegex(email['subject'], "Subject: ALARM LOW .test Alarm.")
  176. self.assertRegex(email['body'], "Alarm .test Alarm.")
  177. self.assertRegex(email['body'], "foo1")
  178. self.assertRegex(email['body'], "foo2")
  179. self.assertRegex(email['body'], "bar1")
  180. self.assertRegex(email['body'], "bar2")
  181. return_value = self.trap.pop(0)
  182. self.assertTrue(return_value)
  183. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  184. def test_smtp_sendmail_failed_connection_twice(self, mock_smtp):
  185. """Email that fails on smtp_connect twice
  186. """
  187. metrics = []
  188. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  189. metrics.append(metric_data)
  190. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  191. metrics.append(metric_data)
  192. mock_log = mock.MagicMock()
  193. mock_log.warn = self.trap.append
  194. mock_log.error = self.trap.append
  195. mock_log.debug = self.trap.append
  196. mock_log.info = self.trap.append
  197. mock_log.exception = self.trap.append
  198. # mock_smtp.SMTP.return_value = mock_smtp
  199. mock_smtp.SMTP.side_effect = [mock_smtp,
  200. smtplib.SMTPServerDisconnected,
  201. socket.error]
  202. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected,
  203. smtplib.SMTPServerDisconnected]
  204. # There has to be a better way to preserve exception definitions when
  205. # we're mocking access to a library
  206. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  207. mock_smtp.SMTPException = smtplib.SMTPException
  208. email = email_notifier.EmailNotifier(mock_log)
  209. email.config()
  210. alarm_dict = alarm(metrics)
  211. notification = Notification(0, 'email', 'email notification',
  212. 'me@here.com', 0, 0, alarm_dict)
  213. self.trap.append(email.send_notification(notification))
  214. self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  215. self.assertIn("Unable to connect to email server.", self.trap)
  216. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  217. def test_smtp_sendmail_smtp_None(self, mock_smtp):
  218. """Email that fails on smtp_connect twice
  219. """
  220. metrics = []
  221. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  222. metrics.append(metric_data)
  223. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  224. metrics.append(metric_data)
  225. mock_log = mock.MagicMock()
  226. mock_log.warn = self.trap.append
  227. mock_log.error = self.trap.append
  228. mock_log.debug = self.trap.append
  229. mock_log.info = self.trap.append
  230. mock_log.exception = self.trap.append
  231. mock_smtp.SMTP.return_value = None
  232. mock_smtp.SMTP.side_effect = [socket.error,
  233. socket.error,
  234. socket.error]
  235. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected,
  236. smtplib.SMTPServerDisconnected]
  237. # There has to be a better way to preserve exception definitions when
  238. # we're mocking access to a library
  239. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  240. mock_smtp.SMTPException = smtplib.SMTPException
  241. email = email_notifier.EmailNotifier(mock_log)
  242. email.config()
  243. del self.trap[:]
  244. alarm_dict = alarm(metrics)
  245. notification = Notification(0, 'email', 'email notification',
  246. 'me@here.com', 0, 0, alarm_dict)
  247. email_result = email.send_notification(notification)
  248. self.assertFalse(email_result)
  249. self.assertIn("Connecting to Email Server my.smtp.server",
  250. self.trap)
  251. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  252. def test_smtp_sendmail_failed_connection_once_then_email(self, mock_smtp):
  253. """Email that fails on smtp_connect once then email
  254. """
  255. metrics = []
  256. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  257. metrics.append(metric_data)
  258. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  259. metrics.append(metric_data)
  260. mock_log = mock.MagicMock()
  261. mock_log.warn = self.trap.append
  262. mock_log.error = self.trap.append
  263. mock_log.debug = self.trap.append
  264. mock_log.info = self.trap.append
  265. mock_log.exception = self.trap.append
  266. mock_smtp.SMTP.return_value = mock_smtp
  267. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected,
  268. smtplib.SMTPException]
  269. # There has to be a better way to preserve exception definitions when
  270. # we're mocking access to a library
  271. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  272. mock_smtp.SMTPException = smtplib.SMTPException
  273. email = email_notifier.EmailNotifier(mock_log)
  274. email.config()
  275. alarm_dict = alarm(metrics)
  276. notification = Notification(0, 'email', 'email notification',
  277. 'me@here.com', 0, 0, alarm_dict)
  278. self.trap.append(email.send_notification(notification))
  279. self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  280. self.assertIn("Error sending Email Notification", self.trap)
  281. self.assertNotIn("Unable to connect to email server.", self.trap)
  282. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  283. def test_smtp_sendmail_failed_connection_once(self, mock_smtp):
  284. """Email that fails on smtp_connect once
  285. """
  286. metrics = []
  287. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  288. metrics.append(metric_data)
  289. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  290. metrics.append(metric_data)
  291. mock_log = mock.MagicMock()
  292. mock_log.warn = self.trap.append
  293. mock_log.error = self.trap.append
  294. mock_log.debug = self.trap.append
  295. mock_log.info = self.trap.append
  296. mock_log.exception = self.trap.append
  297. mock_smtp.SMTP.return_value = mock_smtp
  298. mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected, None]
  299. # There has to be a better way to preserve exception definitions when
  300. # we're mocking access to a library
  301. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  302. mock_smtp.SMTPException = smtplib.SMTPException
  303. email = email_notifier.EmailNotifier(mock_log)
  304. email.config()
  305. alarm_dict = alarm(metrics)
  306. notification = Notification(0, 'email', 'email notification',
  307. 'me@here.com', 0, 0, alarm_dict)
  308. self.trap.append(email.send_notification(notification))
  309. self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  310. self.assertIn("Sent email to %s, notification %s"
  311. % (notification.address, notification.to_json()), self.trap)
  312. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  313. def test_smtp_sendmail_failed_exception(self, mock_smtp):
  314. """Email that fails on exception
  315. """
  316. metrics = []
  317. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  318. metrics.append(metric_data)
  319. metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}}
  320. metrics.append(metric_data)
  321. mock_log = mock.MagicMock()
  322. mock_log.warn = self.trap.append
  323. mock_log.error = self.trap.append
  324. mock_log.debug = self.trap.append
  325. mock_log.info = self.trap.append
  326. mock_log.exception = self.trap.append
  327. mock_smtp.SMTP.return_value = mock_smtp
  328. mock_smtp.sendmail.side_effect = smtplib.SMTPException
  329. # There has to be a better way to preserve exception definitions when
  330. # we're mocking access to a library
  331. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  332. mock_smtp.SMTPException = smtplib.SMTPException
  333. email = email_notifier.EmailNotifier(mock_log)
  334. email.config()
  335. alarm_dict = alarm(metrics)
  336. notification = Notification(0, 'email', 'email notification',
  337. 'me@here.com', 0, 0, alarm_dict)
  338. self.trap.append(email.send_notification(notification))
  339. self.assertNotIn("SMTP server disconnected. Will reconnect and retry message.", self.trap)
  340. self.assertIn("Error sending Email Notification", self.trap)
  341. @mock.patch('monasca_notification.plugins.email_notifier.smtplib')
  342. def test_get_link_url(self, mock_smtp):
  343. # Given one metric with name and dimensions
  344. metrics = []
  345. metric = {'name': 'cpu.percent',
  346. 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}}
  347. metrics.append(metric)
  348. mock_log = mock.MagicMock()
  349. mock_log.warn = self.trap.append
  350. mock_log.error = self.trap.append
  351. mock_log.debug = self.trap.append
  352. mock_log.info = self.trap.append
  353. mock_log.exception = self.trap.append
  354. mock_smtp.SMTP.return_value = mock_smtp
  355. mock_smtp.sendmail.side_effect = smtplib.SMTPException
  356. mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected
  357. mock_smtp.SMTPException = smtplib.SMTPException
  358. email = email_notifier.EmailNotifier(mock_log)
  359. # Create alarm timestamp and timestamp for 'from' and 'to' dates in milliseconds.
  360. alarm_date = datetime.datetime(2017, 6, 7, 18, 0)
  361. alarm_ms, expected_from_ms, expected_to_ms = self.create_time_data(alarm_date)
  362. # When retrieving the link to Grafana for the first metric and given timestamp
  363. result_url = email._get_link_url(metrics[0], alarm_ms)
  364. self.assertIsNotNone(result_url)
  365. # Then the following link to Grafana (including the metric info and timestamp) is expected.
  366. expected_url = "http://127.0.0.1:3000/dashboard/script/drilldown.js" \
  367. "?metric=cpu.percent&dim_hostname=foo1&dim_service=bar1" \
  368. "&from=%s&to=%s" % (expected_from_ms, expected_to_ms)
  369. self._assert_equal_urls(expected_url, result_url)
  370. def create_time_data(self, alarm_date):
  371. epoch = datetime.datetime.utcfromtimestamp(0)
  372. alarm_ms = int(round((alarm_date - epoch).total_seconds() * 1000))
  373. # From and to dates are 10 minutes before and after the alarm occurred.
  374. from_date = alarm_date - datetime.timedelta(minutes=10)
  375. to_date = alarm_date + datetime.timedelta(minutes=10)
  376. expected_from_ms = int(round((from_date - epoch).total_seconds() * 1000))
  377. expected_to_ms = int(round((to_date - epoch).total_seconds() * 1000))
  378. return alarm_ms, expected_from_ms, expected_to_ms
  379. def _assert_equal_urls(self, expected_url, result_url):
  380. if six.PY2:
  381. expected_parsed = urlparse.urlparse(expected_url)
  382. result_parsed = urlparse.urlparse(result_url)
  383. else:
  384. expected_parsed = urlparse(expected_url)
  385. result_parsed = urlparse(result_url)
  386. self.assertEqual(expected_parsed.netloc, result_parsed.netloc)
  387. self.assertEqual(expected_parsed.path, result_parsed.path)
  388. if six.PY2:
  389. expected_parsed_query = urlparse.parse_qs(expected_parsed.query)
  390. result_parsed_query = urlparse.parse_qs(result_parsed.query)
  391. else:
  392. expected_parsed_query = parse.parse_qs(expected_parsed.query)
  393. result_parsed_query = parse.parse_qs(result_parsed.query)
  394. self.assertEqual(len(expected_parsed_query), len(result_parsed_query))
  395. for key in six.iterkeys(result_parsed_query):
  396. self.assertEqual(expected_parsed_query[key], result_parsed_query[key])