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:
Joao Mesquita 2016-05-12 23:47:02 -03:00
parent eb14f1afb6
commit 0c26ab5c8d
5 changed files with 241 additions and 37 deletions

View file

@ -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
```

View file

@ -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()

View file

@ -0,0 +1,2 @@
[aliases]
test=pytest

View file

@ -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',

View 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