Move email rules to more flexible JSON format
* alerta-mailer now has unit tests using py.test * rules should be ported to new JSON format
This commit is contained in:
parent
eb14f1afb6
commit
0c26ab5c8d
5 changed files with 241 additions and 37 deletions
integrations/mailer
|
@ -53,25 +53,34 @@ email_type = text
|
|||
```
|
||||
|
||||
Notifications to other emails according regexp criteria can be enabled,
|
||||
creating a section and calling them with the ``notification`` prefix see the example
|
||||
above.
|
||||
creating a JSON formatted file under ```alerta.rules.d/``` with the following format:
|
||||
|
||||
```
|
||||
[notification:foo]
|
||||
field = resource
|
||||
regex = db-\w+
|
||||
contacts = dba@lists.mycompany.com, dev@lists.mycompany.com
|
||||
|
||||
[notification:bar]
|
||||
field = resource
|
||||
regex = web-\w+
|
||||
contacts = dev@lists.mycompany.com
|
||||
[
|
||||
{
|
||||
"name": "foo",
|
||||
"fields": [
|
||||
{"field": "resource", "regex": "db-\w+"}
|
||||
],
|
||||
"contacts": ["dba@lists.mycompany.com", "dev@lists.mycompany.com"]
|
||||
},
|
||||
{
|
||||
"name": "bar",
|
||||
"fields": [
|
||||
{"field": "resource", "regex": "web-\w+"}
|
||||
],
|
||||
"contacts": ["dev@lists.mycompany.com"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
field is a reference to the alert object, regex is a valid python regexp and
|
||||
contacts are a list (comma separated) of mails who will receive an e-mail if
|
||||
``field``` is a reference to the alert object, regex is a valid python regexp and
|
||||
contacts are a list of mails who will receive an e-mail if
|
||||
the regular expression matches.
|
||||
|
||||
Multiple ```field``` dictionary can be supplied and all ```regex``` must match for
|
||||
the email to be sent.
|
||||
|
||||
Environment Variables
|
||||
---------------------
|
||||
|
||||
|
@ -97,6 +106,9 @@ you have a config file called ``mailer.conf`` on ``/etc/alerta/`` you will need
|
|||
to create the directory ``mailer.conf.d`` at the same level of your config file
|
||||
(mailer.conf in this example), and place all your configs there.
|
||||
|
||||
Multiple email rules files can be supplied as well and rules are going to be applied
|
||||
top-down as they appear on the filesystem and on the files themselves.
|
||||
|
||||
Deployment
|
||||
----------
|
||||
|
||||
|
@ -107,3 +119,12 @@ Dependencies
|
|||
------------
|
||||
|
||||
The Alerta server *MUST* have the AMQP plugin enabled and configured. See [default settings](https://github.com/guardian/alerta/blob/master/alerta/settings.py#L57)
|
||||
|
||||
Testing
|
||||
-------
|
||||
|
||||
Running unit-tests should required nothing else but running:
|
||||
|
||||
```
|
||||
python setup.py test
|
||||
```
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
|
@ -191,25 +192,30 @@ class MailSender(threading.Thread):
|
|||
the subject and text template and using all the other smtp settings
|
||||
that were specified in the configuration file
|
||||
"""
|
||||
contacts = list(OPTIONS['mail_to'])
|
||||
LOG.debug('Initial contact list: %s' % (contacts))
|
||||
contacts = OPTIONS['mail_to']
|
||||
LOG.debug('Initial contact list: %s', contacts)
|
||||
if 'group_rules' in OPTIONS and len(OPTIONS['group_rules']) > 0:
|
||||
LOG.debug('Checking %d group rules' % len(OPTIONS['group_rules']))
|
||||
for rules in OPTIONS['group_rules']:
|
||||
LOG.debug('Matching regex %s to %s (%s)' % (rules['regex'],
|
||||
rules['field'],
|
||||
getattr(alert, rules['field'], None)))
|
||||
if re.match(rules['regex'],
|
||||
getattr(alert, rules['field'], None)):
|
||||
LOG.debug('Regex matched')
|
||||
for rule in OPTIONS['group_rules']:
|
||||
LOG.info('Evaluating rule %s', rule['name'])
|
||||
is_matching = True
|
||||
for field in rule['fields']:
|
||||
LOG.debug('Matching regex %s to %s (%s)' % (field['regex'],
|
||||
field['field'],
|
||||
getattr(alert, field['field'], None)))
|
||||
if re.match(field['regex'],
|
||||
getattr(alert, field['field'], None)):
|
||||
LOG.debug('Regex matched')
|
||||
else:
|
||||
LOG.debug('regex did not match')
|
||||
is_matching = False
|
||||
if is_matching:
|
||||
# Add up any new contacts
|
||||
new_contacts = [x.strip() for x in rules['contacts'].split(',')
|
||||
new_contacts = [x.strip() for x in rule['contacts']
|
||||
if x.strip() not in contacts]
|
||||
if len(new_contacts) > 0:
|
||||
LOG.debug('Extending contact to include %s' % (new_contacts))
|
||||
contacts.extend(new_contacts)
|
||||
else:
|
||||
LOG.debug('regex did not match')
|
||||
|
||||
template_vars = {
|
||||
'alert': alert,
|
||||
|
@ -305,18 +311,58 @@ class MailSender(threading.Thread):
|
|||
mx.close()
|
||||
|
||||
|
||||
def parse_group_rules(config):
|
||||
notifications = [x for x in config.sections()
|
||||
if 'notification' == x.split(':')[0]]
|
||||
rules = []
|
||||
def validate_rules(rules):
|
||||
'''
|
||||
Validates that rules are correct
|
||||
'''
|
||||
if not isinstance(rules, list):
|
||||
LOG.warning('Invalid rules, must be list')
|
||||
return
|
||||
valid_rules = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict):
|
||||
LOG.warning('Invalid rule %s, must be dict', rule)
|
||||
continue
|
||||
valid = True
|
||||
# TODO: This could be optimized to use sets instead
|
||||
for key in ['name', 'fields', 'contacts']:
|
||||
if key not in rule:
|
||||
LOG.warning('Invalid rule %s, must have %s', rule, key)
|
||||
valid = False
|
||||
break
|
||||
if valid is False:
|
||||
continue
|
||||
if not isinstance(rule['fields'], list):
|
||||
LOG.warning('Rule fields must be a list')
|
||||
continue
|
||||
for field in rule['fields']:
|
||||
for key in ['regex', 'field']:
|
||||
if key not in rule['fields']:
|
||||
LOG.warning('Invalid rule %s, must have %s on fields',
|
||||
rule, key)
|
||||
valid = False
|
||||
break
|
||||
|
||||
for notification in notifications:
|
||||
regex = config.get(notification, 'regex')
|
||||
contacts = config.get(notification, 'contacts')
|
||||
field = config.get(notification, 'field')
|
||||
rules.append({'regex': regex, 'contacts': contacts, 'field': field})
|
||||
LOG.info('Adding rule %s to list of rules to be evaluated', rule)
|
||||
valid_rules.append(rule)
|
||||
return valid_rules
|
||||
|
||||
return rules
|
||||
|
||||
def parse_group_rules(config_file):
|
||||
rules_dir = "{}.rules.d".format(config_file)
|
||||
LOG.debug('Looking for rules files in %s', rules_dir)
|
||||
if os.path.exists(rules_dir):
|
||||
rules_d = []
|
||||
for files in os.walk(rules_dir):
|
||||
LOG.debug('Parsing %s', files[2])
|
||||
try:
|
||||
with open(files[2], 'r') as f:
|
||||
rules = validate_rules(json.load(f))
|
||||
if rules is not None:
|
||||
rules_d.extend(rules)
|
||||
except:
|
||||
LOG.exception('Could not parse file')
|
||||
return rules_d
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -368,7 +414,9 @@ def main():
|
|||
if os.environ.get('DEBUG'):
|
||||
OPTIONS['debug'] = True
|
||||
|
||||
OPTIONS['group_rules'] = parse_group_rules(config)
|
||||
group_rules = parse_group_rules(config_file)
|
||||
if group_rules is not None:
|
||||
OPTIONS['group_rules'] = group_rules
|
||||
|
||||
try:
|
||||
mailer = MailSender()
|
||||
|
|
2
integrations/mailer/setup.cfg
Normal file
2
integrations/mailer/setup.cfg
Normal file
|
@ -0,0 +1,2 @@
|
|||
[aliases]
|
||||
test=pytest
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import setuptools
|
||||
|
||||
version = '3.3.2'
|
||||
version = '3.4.0'
|
||||
|
||||
setuptools.setup(
|
||||
name="alerta-mailer",
|
||||
|
@ -14,6 +14,8 @@ setuptools.setup(
|
|||
author_email='nick.satterly@theguardian.com',
|
||||
py_modules=['mailer'],
|
||||
data_files=[('.', ['email.tmpl', 'email.html.tmpl'])],
|
||||
setup_requires=['pytest-runner'],
|
||||
tests_require=['pytest', 'mock', 'pytest-capturelog'],
|
||||
install_requires=[
|
||||
'alerta',
|
||||
'kombu',
|
||||
|
|
131
integrations/mailer/tests/test_rules.py
Normal file
131
integrations/mailer/tests/test_rules.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
'''
|
||||
Unit test definitions for all rules
|
||||
'''
|
||||
import pytest
|
||||
import mailer
|
||||
from mock import MagicMock, patch, DEFAULT
|
||||
|
||||
|
||||
def test_rules_dont_exist():
|
||||
'''
|
||||
Test the rules file is read
|
||||
'''
|
||||
with patch('mailer.os') as system_os:
|
||||
system_os.path.exists.return_value = False
|
||||
res = mailer.parse_group_rules('config_file')
|
||||
system_os.path.exists.called_once_with('confile_file')
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_rules_parsing():
|
||||
'''
|
||||
Test the rules file is properly read
|
||||
'''
|
||||
with patch.multiple(mailer, os=DEFAULT, open=DEFAULT,
|
||||
json=DEFAULT, validate_rules=DEFAULT) as mocks:
|
||||
mocks['os'].path.exists.return_value = True
|
||||
mocks['os'].walk().__iter__\
|
||||
.return_value = [(None, None, 'cantopen.json'),
|
||||
(None, None, 'invalid.json'),
|
||||
(None, None, 'valid.json')]
|
||||
invalid_file = MagicMock()
|
||||
valid_file = MagicMock()
|
||||
mocks['open'].side_effect = [IOError, invalid_file, valid_file]
|
||||
doc = [{'notify': {'fields': []}}]
|
||||
mocks['json'].load.side_effect = [TypeError, doc]
|
||||
mocks['validate_rules'].return_value = doc
|
||||
res = mailer.parse_group_rules('config_file')
|
||||
|
||||
# Assert that we checked for folder existence
|
||||
mocks['os'].path.exists.called_once_with('confile_file')
|
||||
|
||||
# Check that validation was called for valid file
|
||||
mocks['validate_rules'].assert_called_once_with(doc)
|
||||
assert mocks['validate_rules'].call_count == 1
|
||||
|
||||
# Assert that we tried to open all 3 files
|
||||
assert mocks['open'].call_count == 3
|
||||
# Assert that we tried to load 2 files only
|
||||
assert mocks['json'].load.call_count == 2
|
||||
# Assert that we have proper return value
|
||||
assert res == doc
|
||||
|
||||
|
||||
TESTDOCS = [
|
||||
('', False),
|
||||
('String', False),
|
||||
({}, False),
|
||||
([], True),
|
||||
([
|
||||
{"name": "invalid_no_fields"}
|
||||
], False),
|
||||
([
|
||||
{"name": "invalid_empty_fields",
|
||||
"fields": []}
|
||||
], False),
|
||||
([
|
||||
{"name": "invalid_no_contacts",
|
||||
"fields": [{"field": "resource", "regex": r"\d{4}"}]}
|
||||
], False),
|
||||
([
|
||||
{"name": "invalid_no_fields_field",
|
||||
"fields": [{"regex": r"\d{4}"}]}
|
||||
], False),
|
||||
([
|
||||
{"name": "invalid_no_fields_not_list",
|
||||
"fields": {"regex": r"\d{4}"}}
|
||||
], False),
|
||||
([
|
||||
{"name": "invalid_no_fields_regex",
|
||||
"fields": [{"field": "test"}]}
|
||||
], False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('doc, is_valid', TESTDOCS)
|
||||
def test_rules_validation(doc, is_valid):
|
||||
'''
|
||||
Test rule validation
|
||||
'''
|
||||
res = mailer.validate_rules(doc)
|
||||
if is_valid:
|
||||
assert res is not None
|
||||
else:
|
||||
assert res is None or res == []
|
||||
|
||||
|
||||
RULES_DATA = [
|
||||
[],
|
||||
[{
|
||||
"name": "Test1",
|
||||
"fields": [{"field": "resource", "regex": r"\d{4}"}],
|
||||
"contacts": ["test@example.com"]
|
||||
}]
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rules', RULES_DATA)
|
||||
def test_rules_evaluation(rules):
|
||||
'''
|
||||
Test that rules are properly evaluated
|
||||
'''
|
||||
with patch.dict(mailer.OPTIONS, mailer.DEFAULT_OPTIONS):
|
||||
contacts = MagicMock()
|
||||
mailer.OPTIONS['mail_to'] = contacts
|
||||
print contacts, list(contacts)
|
||||
mailer.OPTIONS['group_rules'] = rules
|
||||
mail_sender = mailer.MailSender()
|
||||
with patch.object(mail_sender, '_send_email_message') as _sem:
|
||||
with patch.object(mailer, 're') as regex:
|
||||
alert = MagicMock()
|
||||
mail_sender.send_email(alert)
|
||||
assert _sem.call_count == 1
|
||||
call_count = 0
|
||||
for rule in rules:
|
||||
contacts.extend.assert_called_once_with(rule['contacts'])
|
||||
call_count += len(rule['fields'])
|
||||
for fields in rule['fields']:
|
||||
regex.match.assert_called_with(
|
||||
fields['regex'],
|
||||
getattr(alert, fields['field']))
|
||||
assert regex.match.call_count == call_count
|
Loading…
Add table
Reference in a new issue