From 57de88491b18ebab7e95398458446e5b0cf25d14 Mon Sep 17 00:00:00 2001 From: Feroz Salam Date: Mon, 17 May 2021 15:58:03 +0100 Subject: [PATCH 1/2] Migrate email alerter --- elastalert/alerters/email.py | 133 +++++++++++++++++++++++++++++++++++ elastalert/alerts.py | 127 --------------------------------- elastalert/loaders.py | 3 +- tests/alerts_test.py | 30 ++++---- tests/loaders_test.py | 5 +- 5 files changed, 153 insertions(+), 145 deletions(-) create mode 100644 elastalert/alerters/email.py diff --git a/elastalert/alerters/email.py b/elastalert/alerters/email.py new file mode 100644 index 00000000..c921373f --- /dev/null +++ b/elastalert/alerters/email.py @@ -0,0 +1,133 @@ +import os + +from ..alerts import Alerter +from ..util import elastalert_logger +from ..util import lookup_es_key +from ..util import EAException +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.image import MIMEImage +from email.utils import formatdate +from socket import error +from smtplib import SMTP +from smtplib import SMTP_SSL +from smtplib import SMTPAuthenticationError +from smtplib import SMTPException + + +class EmailAlerter(Alerter): + """ Sends an email alert """ + required_options = frozenset(['email']) + + def __init__(self, *args): + super(EmailAlerter, self).__init__(*args) + + self.assets_dir = self.rule.get('assets_dir', '/tmp') + self.images_dictionary = dict(zip(self.rule.get('email_image_keys', []), self.rule.get('email_image_values', []))) + self.smtp_host = self.rule.get('smtp_host', 'localhost') + self.smtp_ssl = self.rule.get('smtp_ssl', False) + self.from_addr = self.rule.get('from_addr', 'ElastAlert') + self.smtp_port = self.rule.get('smtp_port', 25) + if self.rule.get('smtp_auth_file'): + self.get_account(self.rule['smtp_auth_file']) + self.smtp_key_file = self.rule.get('smtp_key_file') + self.smtp_cert_file = self.rule.get('smtp_cert_file') + # Convert email to a list if it isn't already + if isinstance(self.rule['email'], str): + self.rule['email'] = [self.rule['email']] + # If there is a cc then also convert it a list if it isn't + cc = self.rule.get('cc') + if cc and isinstance(cc, str): + self.rule['cc'] = [self.rule['cc']] + # If there is a bcc then also convert it to a list if it isn't + bcc = self.rule.get('bcc') + if bcc and isinstance(bcc, str): + self.rule['bcc'] = [self.rule['bcc']] + add_suffix = self.rule.get('email_add_domain') + if add_suffix and not add_suffix.startswith('@'): + self.rule['email_add_domain'] = '@' + add_suffix + + def alert(self, matches): + body = self.create_alert_body(matches) + + # Add JIRA ticket if it exists + if self.pipeline is not None and 'jira_ticket' in self.pipeline: + url = '%s/browse/%s' % (self.pipeline['jira_server'], self.pipeline['jira_ticket']) + body += '\nJIRA ticket: %s' % (url) + + to_addr = self.rule['email'] + if 'email_from_field' in self.rule: + recipient = lookup_es_key(matches[0], self.rule['email_from_field']) + if isinstance(recipient, str): + if '@' in recipient: + to_addr = [recipient] + elif 'email_add_domain' in self.rule: + to_addr = [recipient + self.rule['email_add_domain']] + elif isinstance(recipient, list): + to_addr = recipient + if 'email_add_domain' in self.rule: + to_addr = [name + self.rule['email_add_domain'] for name in to_addr] + if self.rule.get('email_format') == 'html': + # email_msg = MIMEText(body, 'html', _charset='UTF-8') # old way + email_msg = MIMEMultipart() + msgText = MIMEText(body, 'html', _charset='UTF-8') + email_msg.attach(msgText) # Added, and edited the previous line + + for image_key in self.images_dictionary: + fp = open(os.path.join(self.assets_dir, self.images_dictionary[image_key]), 'rb') + img = MIMEImage(fp.read()) + fp.close() + img.add_header('Content-ID', '<{}>'.format(image_key)) + email_msg.attach(img) + else: + email_msg = MIMEText(body, _charset='UTF-8') + email_msg['Subject'] = self.create_title(matches) + email_msg['To'] = ', '.join(to_addr) + email_msg['From'] = self.from_addr + email_msg['Reply-To'] = self.rule.get('email_reply_to', email_msg['To']) + email_msg['Date'] = formatdate() + if self.rule.get('cc'): + email_msg['CC'] = ','.join(self.rule['cc']) + to_addr = to_addr + self.rule['cc'] + if self.rule.get('bcc'): + to_addr = to_addr + self.rule['bcc'] + + try: + if self.smtp_ssl: + if self.smtp_port: + self.smtp = SMTP_SSL(self.smtp_host, self.smtp_port, keyfile=self.smtp_key_file, certfile=self.smtp_cert_file) + else: + self.smtp = SMTP_SSL(self.smtp_host, keyfile=self.smtp_key_file, certfile=self.smtp_cert_file) + else: + if self.smtp_port: + self.smtp = SMTP(self.smtp_host, self.smtp_port) + else: + self.smtp = SMTP(self.smtp_host) + self.smtp.ehlo() + if self.smtp.has_extn('STARTTLS'): + self.smtp.starttls(keyfile=self.smtp_key_file, certfile=self.smtp_cert_file) + if 'smtp_auth_file' in self.rule: + self.smtp.login(self.user, self.password) + except (SMTPException, error) as e: + raise EAException("Error connecting to SMTP host: %s" % (e)) + except SMTPAuthenticationError as e: + raise EAException("SMTP username/password rejected: %s" % (e)) + self.smtp.sendmail(self.from_addr, to_addr, email_msg.as_string()) + self.smtp.quit() + + elastalert_logger.info("Sent email to %s" % (to_addr)) + + def create_default_title(self, matches): + subject = 'ElastAlert: %s' % (self.rule['name']) + + # If the rule has a query_key, add that value plus timestamp to subject + if 'query_key' in self.rule: + qk = matches[0].get(self.rule['query_key']) + if qk: + subject += ' - %s' % (qk) + + return subject + + def get_info(self): + return {'type': 'email', + 'recipients': self.rule['email']} diff --git a/elastalert/alerts.py b/elastalert/alerts.py index 1b508148..7052d014 100644 --- a/elastalert/alerts.py +++ b/elastalert/alerts.py @@ -8,15 +8,6 @@ import time import uuid import warnings -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from email.mime.image import MIMEImage -from email.utils import formatdate -from smtplib import SMTP -from smtplib import SMTP_SSL -from smtplib import SMTPAuthenticationError -from smtplib import SMTPException -from socket import error import boto3 import requests @@ -403,124 +394,6 @@ def get_info(self): return {'type': 'debug'} -class EmailAlerter(Alerter): - """ Sends an email alert """ - required_options = frozenset(['email']) - - def __init__(self, *args): - super(EmailAlerter, self).__init__(*args) - - self.assets_dir = self.rule.get('assets_dir', '/tmp') - self.images_dictionary = dict(zip(self.rule.get('email_image_keys', []), self.rule.get('email_image_values', []))) - self.smtp_host = self.rule.get('smtp_host', 'localhost') - self.smtp_ssl = self.rule.get('smtp_ssl', False) - self.from_addr = self.rule.get('from_addr', 'ElastAlert') - self.smtp_port = self.rule.get('smtp_port', 25) - if self.rule.get('smtp_auth_file'): - self.get_account(self.rule['smtp_auth_file']) - self.smtp_key_file = self.rule.get('smtp_key_file') - self.smtp_cert_file = self.rule.get('smtp_cert_file') - # Convert email to a list if it isn't already - if isinstance(self.rule['email'], str): - self.rule['email'] = [self.rule['email']] - # If there is a cc then also convert it a list if it isn't - cc = self.rule.get('cc') - if cc and isinstance(cc, str): - self.rule['cc'] = [self.rule['cc']] - # If there is a bcc then also convert it to a list if it isn't - bcc = self.rule.get('bcc') - if bcc and isinstance(bcc, str): - self.rule['bcc'] = [self.rule['bcc']] - add_suffix = self.rule.get('email_add_domain') - if add_suffix and not add_suffix.startswith('@'): - self.rule['email_add_domain'] = '@' + add_suffix - - def alert(self, matches): - body = self.create_alert_body(matches) - - # Add JIRA ticket if it exists - if self.pipeline is not None and 'jira_ticket' in self.pipeline: - url = '%s/browse/%s' % (self.pipeline['jira_server'], self.pipeline['jira_ticket']) - body += '\nJIRA ticket: %s' % (url) - - to_addr = self.rule['email'] - if 'email_from_field' in self.rule: - recipient = lookup_es_key(matches[0], self.rule['email_from_field']) - if isinstance(recipient, str): - if '@' in recipient: - to_addr = [recipient] - elif 'email_add_domain' in self.rule: - to_addr = [recipient + self.rule['email_add_domain']] - elif isinstance(recipient, list): - to_addr = recipient - if 'email_add_domain' in self.rule: - to_addr = [name + self.rule['email_add_domain'] for name in to_addr] - if self.rule.get('email_format') == 'html': - # email_msg = MIMEText(body, 'html', _charset='UTF-8') # old way - email_msg = MIMEMultipart() - msgText = MIMEText(body, 'html', _charset='UTF-8') - email_msg.attach(msgText) # Added, and edited the previous line - - for image_key in self.images_dictionary: - fp = open(os.path.join(self.assets_dir, self.images_dictionary[image_key]), 'rb') - img = MIMEImage(fp.read()) - fp.close() - img.add_header('Content-ID', '<{}>'.format(image_key)) - email_msg.attach(img) - else: - email_msg = MIMEText(body, _charset='UTF-8') - email_msg['Subject'] = self.create_title(matches) - email_msg['To'] = ', '.join(to_addr) - email_msg['From'] = self.from_addr - email_msg['Reply-To'] = self.rule.get('email_reply_to', email_msg['To']) - email_msg['Date'] = formatdate() - if self.rule.get('cc'): - email_msg['CC'] = ','.join(self.rule['cc']) - to_addr = to_addr + self.rule['cc'] - if self.rule.get('bcc'): - to_addr = to_addr + self.rule['bcc'] - - try: - if self.smtp_ssl: - if self.smtp_port: - self.smtp = SMTP_SSL(self.smtp_host, self.smtp_port, keyfile=self.smtp_key_file, certfile=self.smtp_cert_file) - else: - self.smtp = SMTP_SSL(self.smtp_host, keyfile=self.smtp_key_file, certfile=self.smtp_cert_file) - else: - if self.smtp_port: - self.smtp = SMTP(self.smtp_host, self.smtp_port) - else: - self.smtp = SMTP(self.smtp_host) - self.smtp.ehlo() - if self.smtp.has_extn('STARTTLS'): - self.smtp.starttls(keyfile=self.smtp_key_file, certfile=self.smtp_cert_file) - if 'smtp_auth_file' in self.rule: - self.smtp.login(self.user, self.password) - except (SMTPException, error) as e: - raise EAException("Error connecting to SMTP host: %s" % (e)) - except SMTPAuthenticationError as e: - raise EAException("SMTP username/password rejected: %s" % (e)) - self.smtp.sendmail(self.from_addr, to_addr, email_msg.as_string()) - self.smtp.quit() - - elastalert_logger.info("Sent email to %s" % (to_addr)) - - def create_default_title(self, matches): - subject = 'ElastAlert: %s' % (self.rule['name']) - - # If the rule has a query_key, add that value plus timestamp to subject - if 'query_key' in self.rule: - qk = matches[0].get(self.rule['query_key']) - if qk: - subject += ' - %s' % (qk) - - return subject - - def get_info(self): - return {'type': 'email', - 'recipients': self.rule['email']} - - class JiraAlerter(Alerter): """ Creates a Jira ticket for each alert """ required_options = frozenset(['jira_server', 'jira_account_file', 'jira_project', 'jira_issuetype']) diff --git a/elastalert/loaders.py b/elastalert/loaders.py index 013be0dd..5a336c0c 100644 --- a/elastalert/loaders.py +++ b/elastalert/loaders.py @@ -15,6 +15,7 @@ from . import alerts from . import enhancements from . import ruletypes +from .alerters.email import EmailAlerter from .alerters.opsgenie import OpsGenieAlerter from .alerters.zabbix import ZabbixAlerter from .util import dt_to_ts @@ -59,7 +60,7 @@ class RulesLoader(object): # Used to map names of alerts to their classes alerts_mapping = { - 'email': alerts.EmailAlerter, + 'email': EmailAlerter, 'jira': alerts.JiraAlerter, 'opsgenie': OpsGenieAlerter, 'stomp': alerts.StompAlerter, diff --git a/tests/alerts_test.py b/tests/alerts_test.py index 4e22b33c..db6eba7d 100644 --- a/tests/alerts_test.py +++ b/tests/alerts_test.py @@ -20,7 +20,6 @@ from elastalert.alerts import DatadogAlerter from elastalert.alerts import DingTalkAlerter from elastalert.alerts import DiscordAlerter -from elastalert.alerts import EmailAlerter from elastalert.alerts import GitterAlerter from elastalert.alerts import GoogleChatAlerter from elastalert.alerts import HiveAlerter @@ -36,6 +35,7 @@ from elastalert.alerts import SlackAlerter from elastalert.alerts import TelegramAlerter from elastalert.loaders import FileRulesLoader +from elastalert.alerters.email import EmailAlerter from elastalert.alerters.opsgenie import OpsGenieAlerter from elastalert.alerters.zabbix import ZabbixAlerter from elastalert.alerts import VictorOpsAlerter @@ -113,7 +113,7 @@ def test_email(): rule = {'name': 'test alert', 'email': ['testing@test.test', 'test@test.test'], 'from_addr': 'testfrom@test.test', 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'owner': 'owner_value', 'alert_subject': 'Test alert for {0}, owned by {1}', 'alert_subject_args': ['test_term', 'owner'], 'snowman': '☃'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) @@ -138,7 +138,7 @@ def test_email_from_field(): rule = {'name': 'test alert', 'email': ['testing@test.test'], 'email_add_domain': 'example.com', 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_from_field': 'data.user', 'owner': 'owner_value'} # Found, without @ - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) alert.alert([{'data': {'user': 'qlo'}}]) @@ -146,28 +146,28 @@ def test_email_from_field(): # Found, with @ rule['email_add_domain'] = '@example.com' - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) alert.alert([{'data': {'user': 'qlo'}}]) assert mock_smtp.mock_calls[4][1][1] == ['qlo@example.com'] # Found, list - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) alert.alert([{'data': {'user': ['qlo', 'foo']}}]) assert mock_smtp.mock_calls[4][1][1] == ['qlo@example.com', 'foo@example.com'] # Not found - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) alert.alert([{'data': {'foo': 'qlo'}}]) assert mock_smtp.mock_calls[4][1][1] == ['testing@test.test'] # Found, wrong type - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) alert.alert([{'data': {'user': 17}}]) @@ -178,7 +178,7 @@ def test_email_with_unicode_strings(): rule = {'name': 'test alert', 'email': 'testing@test.test', 'from_addr': 'testfrom@test.test', 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'owner': 'owner_value', 'alert_subject': 'Test alert for {0}, owned by {1}', 'alert_subject_args': ['test_term', 'owner'], 'snowman': '☃'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) @@ -204,7 +204,7 @@ def test_email_with_auth(): 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'alert_subject': 'Test alert for {0}', 'alert_subject_args': ['test_term'], 'smtp_auth_file': 'file.txt', 'rule_file': '/tmp/foo.yaml'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: with mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'someone', 'password': 'hunter2'} mock_smtp.return_value = mock.Mock() @@ -226,7 +226,7 @@ def test_email_with_cert_key(): 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'alert_subject': 'Test alert for {0}', 'alert_subject_args': ['test_term'], 'smtp_auth_file': 'file.txt', 'smtp_cert_file': 'dummy/cert.crt', 'smtp_key_file': 'dummy/client.key', 'rule_file': '/tmp/foo.yaml'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: with mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'someone', 'password': 'hunter2'} mock_smtp.return_value = mock.Mock() @@ -247,7 +247,7 @@ def test_email_with_cc(): rule = {'name': 'test alert', 'email': ['testing@test.test', 'test@test.test'], 'from_addr': 'testfrom@test.test', 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'cc': 'tester@testing.testing'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) @@ -272,7 +272,7 @@ def test_email_with_bcc(): rule = {'name': 'test alert', 'email': ['testing@test.test', 'test@test.test'], 'from_addr': 'testfrom@test.test', 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'bcc': 'tester@testing.testing'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) @@ -297,7 +297,7 @@ def test_email_with_cc_and_bcc(): rule = {'name': 'test alert', 'email': ['testing@test.test', 'test@test.test'], 'from_addr': 'testfrom@test.test', 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'cc': ['test1@test.com', 'test2@test.com'], 'bcc': 'tester@testing.testing'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) @@ -342,7 +342,7 @@ def test_email_with_args(): 'alert_text_args': ['test_arg1', 'test_arg2', 'test.arg3'], 'alert_missing_value': '' } - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) @@ -373,7 +373,7 @@ def test_email_query_key_in_subject(): rule = {'name': 'test alert', 'email': ['testing@test.test', 'test@test.test'], 'type': mock_rule(), 'timestamp_field': '@timestamp', 'email_reply_to': 'test@example.com', 'query_key': 'username'} - with mock.patch('elastalert.alerts.SMTP') as mock_smtp: + with mock.patch('elastalert.alerters.email.SMTP') as mock_smtp: mock_smtp.return_value = mock.Mock() alert = EmailAlerter(rule) diff --git a/tests/loaders_test.py b/tests/loaders_test.py index be29dfa9..834dbc8c 100644 --- a/tests/loaders_test.py +++ b/tests/loaders_test.py @@ -8,6 +8,7 @@ import elastalert.alerts import elastalert.ruletypes +from elastalert.alerters.email import EmailAlerter from elastalert.config import load_conf from elastalert.loaders import FileRulesLoader from elastalert.util import EAException @@ -160,8 +161,8 @@ def test_load_inline_alert_rule(): with mock.patch.object(rules_loader, 'get_yaml') as mock_open: mock_open.side_effect = [test_config_copy, test_rule_copy] rules_loader.load_modules(test_rule_copy) - assert isinstance(test_rule_copy['alert'][0], elastalert.alerts.EmailAlerter) - assert isinstance(test_rule_copy['alert'][1], elastalert.alerts.EmailAlerter) + assert isinstance(test_rule_copy['alert'][0], EmailAlerter) + assert isinstance(test_rule_copy['alert'][1], EmailAlerter) assert 'foo@bar.baz' in test_rule_copy['alert'][0].rule['email'] assert 'baz@foo.bar' in test_rule_copy['alert'][1].rule['email'] From 2ca5969ebe3045037d2043ecfc3fb3fc305b5aa2 Mon Sep 17 00:00:00 2001 From: Feroz Salam Date: Mon, 17 May 2021 17:02:28 +0100 Subject: [PATCH 2/2] Migrate Jira alerter --- elastalert/alerters/jira.py | 399 ++++++++++++++++++++++++++++++++++++ elastalert/alerts.py | 390 ----------------------------------- elastalert/loaders.py | 3 +- tests/alerts_test.py | 24 +-- 4 files changed, 413 insertions(+), 403 deletions(-) create mode 100644 elastalert/alerters/jira.py diff --git a/elastalert/alerters/jira.py b/elastalert/alerters/jira.py new file mode 100644 index 00000000..4614ea40 --- /dev/null +++ b/elastalert/alerters/jira.py @@ -0,0 +1,399 @@ +import datetime +import sys + +from ..alerts import Alerter +from ..alerts import BasicMatchString +from ..util import elastalert_logger +from ..util import lookup_es_key +from ..util import pretty_ts +from ..util import ts_now +from ..util import ts_to_dt +from ..util import EAException +from jira.client import JIRA +from jira.exceptions import JIRAError + + +class JiraFormattedMatchString(BasicMatchString): + def _add_match_items(self): + match_items = dict([(x, y) for x, y in list(self.match.items()) if not x.startswith('top_events_')]) + json_blob = self._pretty_print_as_json(match_items) + preformatted_text = '{{code}}{0}{{code}}'.format(json_blob) + self.text += preformatted_text + + +class JiraAlerter(Alerter): + """ Creates a Jira ticket for each alert """ + required_options = frozenset(['jira_server', 'jira_account_file', 'jira_project', 'jira_issuetype']) + + # Maintain a static set of built-in fields that we explicitly know how to set + # For anything else, we will do best-effort and try to set a string value + known_field_list = [ + 'jira_account_file', + 'jira_assignee', + 'jira_bump_after_inactivity', + 'jira_bump_in_statuses', + 'jira_bump_not_in_statuses', + 'jira_bump_only', + 'jira_bump_tickets', + 'jira_component', + 'jira_components', + 'jira_description', + 'jira_ignore_in_title', + 'jira_issuetype', + 'jira_label', + 'jira_labels', + 'jira_max_age', + 'jira_priority', + 'jira_project', + 'jira_server', + 'jira_transition_to', + 'jira_watchers', + ] + + # Some built-in jira types that can be used as custom fields require special handling + # Here is a sample of one of them: + # {"id":"customfield_12807","name":"My Custom Field","custom":true,"orderable":true,"navigable":true,"searchable":true, + # "clauseNames":["cf[12807]","My Custom Field"],"schema":{"type":"array","items":"string", + # "custom":"com.atlassian.jira.plugin.system.customfieldtypes:multiselect","customId":12807}} + # There are likely others that will need to be updated on a case-by-case basis + custom_string_types_with_special_handling = [ + 'com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes', + 'com.atlassian.jira.plugin.system.customfieldtypes:multiselect', + 'com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons', + ] + + def __init__(self, rule): + super(JiraAlerter, self).__init__(rule) + self.server = self.rule['jira_server'] + self.get_account(self.rule['jira_account_file']) + self.project = self.rule['jira_project'] + self.issue_type = self.rule['jira_issuetype'] + + # Deferred settings refer to values that can only be resolved when a match + # is found and as such loading them will be delayed until we find a match + self.deferred_settings = [] + + # We used to support only a single component. This allows us to maintain backwards compatibility + # while also giving the user-facing API a more representative name + self.components = self.rule.get('jira_components', self.rule.get('jira_component')) + + # We used to support only a single label. This allows us to maintain backwards compatibility + # while also giving the user-facing API a more representative name + self.labels = self.rule.get('jira_labels', self.rule.get('jira_label')) + + self.description = self.rule.get('jira_description', '') + self.assignee = self.rule.get('jira_assignee') + self.max_age = self.rule.get('jira_max_age', 30) + self.priority = self.rule.get('jira_priority') + self.bump_tickets = self.rule.get('jira_bump_tickets', False) + self.bump_not_in_statuses = self.rule.get('jira_bump_not_in_statuses') + self.bump_in_statuses = self.rule.get('jira_bump_in_statuses') + self.bump_after_inactivity = self.rule.get('jira_bump_after_inactivity', 0) + self.bump_only = self.rule.get('jira_bump_only', False) + self.transition = self.rule.get('jira_transition_to', False) + self.watchers = self.rule.get('jira_watchers') + self.client = None + + if self.bump_in_statuses and self.bump_not_in_statuses: + msg = 'Both jira_bump_in_statuses (%s) and jira_bump_not_in_statuses (%s) are set.' % \ + (','.join(self.bump_in_statuses), ','.join(self.bump_not_in_statuses)) + intersection = list(set(self.bump_in_statuses) & set(self.bump_in_statuses)) + if intersection: + msg = '%s Both have common statuses of (%s). As such, no tickets will ever be found.' % ( + msg, ','.join(intersection)) + msg += ' This should be simplified to use only one or the other.' + elastalert_logger.warning(msg) + + self.reset_jira_args() + + try: + self.client = JIRA(self.server, basic_auth=(self.user, self.password)) + self.get_priorities() + self.jira_fields = self.client.fields() + self.get_arbitrary_fields() + except JIRAError as e: + # JIRAError may contain HTML, pass along only first 1024 chars + raise EAException("Error connecting to JIRA: %s" % (str(e)[:1024])).with_traceback(sys.exc_info()[2]) + + self.set_priority() + + def set_priority(self): + try: + if self.priority is not None and self.client is not None: + self.jira_args['priority'] = {'id': self.priority_ids[self.priority]} + except KeyError: + elastalert_logger.error("Priority %s not found. Valid priorities are %s" % (self.priority, list(self.priority_ids.keys()))) + + def reset_jira_args(self): + self.jira_args = {'project': {'key': self.project}, + 'issuetype': {'name': self.issue_type}} + + if self.components: + # Support single component or list + if type(self.components) != list: + self.jira_args['components'] = [{'name': self.components}] + else: + self.jira_args['components'] = [{'name': component} for component in self.components] + if self.labels: + # Support single label or list + if type(self.labels) != list: + self.labels = [self.labels] + self.jira_args['labels'] = self.labels + if self.watchers: + # Support single watcher or list + if type(self.watchers) != list: + self.watchers = [self.watchers] + if self.assignee: + self.jira_args['assignee'] = {'name': self.assignee} + + self.set_priority() + + def set_jira_arg(self, jira_field, value, fields): + # Remove the jira_ part. Convert underscores to spaces + normalized_jira_field = jira_field[5:].replace('_', ' ').lower() + # All jira fields should be found in the 'id' or the 'name' field. Therefore, try both just in case + for identifier in ['name', 'id']: + field = next((f for f in fields if normalized_jira_field == f[identifier].replace('_', ' ').lower()), None) + if field: + break + if not field: + # Log a warning to ElastAlert saying that we couldn't find that type? + # OR raise and fail to load the alert entirely? Probably the latter... + raise Exception("Could not find a definition for the jira field '{0}'".format(normalized_jira_field)) + arg_name = field['id'] + # Check the schema information to decide how to set the value correctly + # If the schema information is not available, raise an exception since we don't know how to set it + # Note this is only the case for two built-in types, id: issuekey and id: thumbnail + if not ('schema' in field or 'type' in field['schema']): + raise Exception("Could not determine schema information for the jira field '{0}'".format(normalized_jira_field)) + arg_type = field['schema']['type'] + + # Handle arrays of simple types like strings or numbers + if arg_type == 'array': + # As a convenience, support the scenario wherein the user only provides + # a single value for a multi-value field e.g. jira_labels: Only_One_Label + if type(value) != list: + value = [value] + array_items = field['schema']['items'] + # Simple string types + if array_items in ['string', 'date', 'datetime']: + # Special case for multi-select custom types (the JIRA metadata says that these are strings, but + # in reality, they are required to be provided as an object. + if 'custom' in field['schema'] and field['schema']['custom'] in self.custom_string_types_with_special_handling: + self.jira_args[arg_name] = [{'value': v} for v in value] + else: + self.jira_args[arg_name] = value + elif array_items == 'number': + self.jira_args[arg_name] = [int(v) for v in value] + # Also attempt to handle arrays of complex types that have to be passed as objects with an identifier 'key' + elif array_items == 'option': + self.jira_args[arg_name] = [{'value': v} for v in value] + else: + # Try setting it as an object, using 'name' as the key + # This may not work, as the key might actually be 'key', 'id', 'value', or something else + # If it works, great! If not, it will manifest itself as an API error that will bubble up + self.jira_args[arg_name] = [{'name': v} for v in value] + # Handle non-array types + else: + # Simple string types + if arg_type in ['string', 'date', 'datetime']: + # Special case for custom types (the JIRA metadata says that these are strings, but + # in reality, they are required to be provided as an object. + if 'custom' in field['schema'] and field['schema']['custom'] in self.custom_string_types_with_special_handling: + self.jira_args[arg_name] = {'value': value} + else: + self.jira_args[arg_name] = value + # Number type + elif arg_type == 'number': + self.jira_args[arg_name] = int(value) + elif arg_type == 'option': + self.jira_args[arg_name] = {'value': value} + # Complex type + else: + self.jira_args[arg_name] = {'name': value} + + def get_arbitrary_fields(self): + # Clear jira_args + self.reset_jira_args() + + for jira_field, value in self.rule.items(): + # If we find a field that is not covered by the set that we are aware of, it means it is either: + # 1. A built-in supported field in JIRA that we don't have on our radar + # 2. A custom field that a JIRA admin has configured + if jira_field.startswith('jira_') and jira_field not in self.known_field_list and str(value)[:1] != '#': + self.set_jira_arg(jira_field, value, self.jira_fields) + if jira_field.startswith('jira_') and jira_field not in self.known_field_list and str(value)[:1] == '#': + self.deferred_settings.append(jira_field) + + def get_priorities(self): + """ Creates a mapping of priority index to id. """ + priorities = self.client.priorities() + self.priority_ids = {} + for x in range(len(priorities)): + self.priority_ids[x] = priorities[x].id + + def set_assignee(self, assignee): + self.assignee = assignee + if assignee: + self.jira_args['assignee'] = {'name': assignee} + elif 'assignee' in self.jira_args: + self.jira_args.pop('assignee') + + def find_existing_ticket(self, matches): + # Default title, get stripped search version + if 'alert_subject' not in self.rule: + title = self.create_default_title(matches, True) + else: + title = self.create_title(matches) + + if 'jira_ignore_in_title' in self.rule: + title = title.replace(matches[0].get(self.rule['jira_ignore_in_title'], ''), '') + + # This is necessary for search to work. Other special characters and dashes + # directly adjacent to words appear to be ok + title = title.replace(' - ', ' ') + title = title.replace('\\', '\\\\') + + date = (datetime.datetime.now() - datetime.timedelta(days=self.max_age)).strftime('%Y-%m-%d') + jql = 'project=%s AND summary~"%s" and created >= "%s"' % (self.project, title, date) + if self.bump_in_statuses: + jql = '%s and status in (%s)' % (jql, ','.join(["\"%s\"" % status if ' ' in status else status for status + in self.bump_in_statuses])) + if self.bump_not_in_statuses: + jql = '%s and status not in (%s)' % (jql, ','.join(["\"%s\"" % status if ' ' in status else status + for status in self.bump_not_in_statuses])) + try: + issues = self.client.search_issues(jql) + except JIRAError as e: + elastalert_logger.exception("Error while searching for JIRA ticket using jql '%s': %s" % (jql, e)) + return None + + if len(issues): + return issues[0] + + def comment_on_ticket(self, ticket, match): + text = str(JiraFormattedMatchString(self.rule, match)) + timestamp = pretty_ts(lookup_es_key(match, self.rule['timestamp_field'])) + comment = "This alert was triggered again at %s\n%s" % (timestamp, text) + self.client.add_comment(ticket, comment) + + def transition_ticket(self, ticket): + transitions = self.client.transitions(ticket) + for t in transitions: + if t['name'] == self.transition: + self.client.transition_issue(ticket, t['id']) + + def alert(self, matches): + # Reset arbitrary fields to pick up changes + self.get_arbitrary_fields() + if len(self.deferred_settings) > 0: + fields = self.client.fields() + for jira_field in self.deferred_settings: + value = lookup_es_key(matches[0], self.rule[jira_field][1:]) + self.set_jira_arg(jira_field, value, fields) + + title = self.create_title(matches) + + if self.bump_tickets: + ticket = self.find_existing_ticket(matches) + if ticket: + inactivity_datetime = ts_now() - datetime.timedelta(days=self.bump_after_inactivity) + if ts_to_dt(ticket.fields.updated) >= inactivity_datetime: + if self.pipeline is not None: + self.pipeline['jira_ticket'] = None + self.pipeline['jira_server'] = self.server + return None + elastalert_logger.info('Commenting on existing ticket %s' % (ticket.key)) + for match in matches: + try: + self.comment_on_ticket(ticket, match) + except JIRAError as e: + elastalert_logger.exception("Error while commenting on ticket %s: %s" % (ticket, e)) + if self.labels: + for label in self.labels: + try: + ticket.fields.labels.append(label) + except JIRAError as e: + elastalert_logger.exception("Error while appending labels to ticket %s: %s" % (ticket, e)) + if self.transition: + elastalert_logger.info('Transitioning existing ticket %s' % (ticket.key)) + try: + self.transition_ticket(ticket) + except JIRAError as e: + elastalert_logger.exception("Error while transitioning ticket %s: %s" % (ticket, e)) + + if self.pipeline is not None: + self.pipeline['jira_ticket'] = ticket + self.pipeline['jira_server'] = self.server + return None + if self.bump_only: + return None + + self.jira_args['summary'] = title + self.jira_args['description'] = self.create_alert_body(matches) + + try: + self.issue = self.client.create_issue(**self.jira_args) + + # You can not add watchers on initial creation. Only as a follow-up action + if self.watchers: + for watcher in self.watchers: + try: + self.client.add_watcher(self.issue.key, watcher) + except Exception as ex: + # Re-raise the exception, preserve the stack-trace, and give some + # context as to which watcher failed to be added + raise Exception( + "Exception encountered when trying to add '{0}' as a watcher. Does the user exist?\n{1}" .format( + watcher, + ex + )).with_traceback(sys.exc_info()[2]) + + except JIRAError as e: + raise EAException("Error creating JIRA ticket using jira_args (%s): %s" % (self.jira_args, e)) + elastalert_logger.info("Opened Jira ticket: %s" % (self.issue)) + + if self.pipeline is not None: + self.pipeline['jira_ticket'] = self.issue + self.pipeline['jira_server'] = self.server + + def create_alert_body(self, matches): + body = self.description + '\n' + body += self.get_aggregation_summary_text(matches) + if self.rule.get('alert_text_type') != 'aggregation_summary_only': + for match in matches: + body += str(JiraFormattedMatchString(self.rule, match)) + if len(matches) > 1: + body += '\n----------------------------------------\n' + return body + + def get_aggregation_summary_text(self, matches): + text = super(JiraAlerter, self).get_aggregation_summary_text(matches) + if text: + text = '{{noformat}}{0}{{noformat}}'.format(text) + return text + + def create_default_title(self, matches, for_search=False): + # If there is a query_key, use that in the title + + if 'query_key' in self.rule and lookup_es_key(matches[0], self.rule['query_key']): + title = 'ElastAlert: %s matched %s' % (lookup_es_key(matches[0], self.rule['query_key']), self.rule['name']) + else: + title = 'ElastAlert: %s' % (self.rule['name']) + + if for_search: + return title + + timestamp = matches[0].get(self.rule['timestamp_field']) + if timestamp: + title += ' - %s' % (pretty_ts(timestamp, self.rule.get('use_local_time'))) + + # Add count for spikes + count = matches[0].get('spike_count') + if count: + title += ' - %s+ events' % (count) + + return title + + def get_info(self): + return {'type': 'jira'} diff --git a/elastalert/alerts.py b/elastalert/alerts.py index 7052d014..e207f914 100644 --- a/elastalert/alerts.py +++ b/elastalert/alerts.py @@ -13,8 +13,6 @@ import requests import stomp from exotel import Exotel -from jira.client import JIRA -from jira.exceptions import JIRAError from requests.auth import HTTPProxyAuth from requests.exceptions import RequestException from texttable import Texttable @@ -24,9 +22,7 @@ from .util import EAException from .util import elastalert_logger from .util import lookup_es_key -from .util import pretty_ts from .util import resolve_string -from .util import ts_now from .util import ts_to_dt from .yaml import read_yaml @@ -148,14 +144,6 @@ def __str__(self): return self.text -class JiraFormattedMatchString(BasicMatchString): - def _add_match_items(self): - match_items = dict([(x, y) for x, y in list(self.match.items()) if not x.startswith('top_events_')]) - json_blob = self._pretty_print_as_json(match_items) - preformatted_text = '{{code}}{0}{{code}}'.format(json_blob) - self.text += preformatted_text - - class Alerter(object): """ Base class for types of alerts. @@ -394,384 +382,6 @@ def get_info(self): return {'type': 'debug'} -class JiraAlerter(Alerter): - """ Creates a Jira ticket for each alert """ - required_options = frozenset(['jira_server', 'jira_account_file', 'jira_project', 'jira_issuetype']) - - # Maintain a static set of built-in fields that we explicitly know how to set - # For anything else, we will do best-effort and try to set a string value - known_field_list = [ - 'jira_account_file', - 'jira_assignee', - 'jira_bump_after_inactivity', - 'jira_bump_in_statuses', - 'jira_bump_not_in_statuses', - 'jira_bump_only', - 'jira_bump_tickets', - 'jira_component', - 'jira_components', - 'jira_description', - 'jira_ignore_in_title', - 'jira_issuetype', - 'jira_label', - 'jira_labels', - 'jira_max_age', - 'jira_priority', - 'jira_project', - 'jira_server', - 'jira_transition_to', - 'jira_watchers', - ] - - # Some built-in jira types that can be used as custom fields require special handling - # Here is a sample of one of them: - # {"id":"customfield_12807","name":"My Custom Field","custom":true,"orderable":true,"navigable":true,"searchable":true, - # "clauseNames":["cf[12807]","My Custom Field"],"schema":{"type":"array","items":"string", - # "custom":"com.atlassian.jira.plugin.system.customfieldtypes:multiselect","customId":12807}} - # There are likely others that will need to be updated on a case-by-case basis - custom_string_types_with_special_handling = [ - 'com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes', - 'com.atlassian.jira.plugin.system.customfieldtypes:multiselect', - 'com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons', - ] - - def __init__(self, rule): - super(JiraAlerter, self).__init__(rule) - self.server = self.rule['jira_server'] - self.get_account(self.rule['jira_account_file']) - self.project = self.rule['jira_project'] - self.issue_type = self.rule['jira_issuetype'] - - # Deferred settings refer to values that can only be resolved when a match - # is found and as such loading them will be delayed until we find a match - self.deferred_settings = [] - - # We used to support only a single component. This allows us to maintain backwards compatibility - # while also giving the user-facing API a more representative name - self.components = self.rule.get('jira_components', self.rule.get('jira_component')) - - # We used to support only a single label. This allows us to maintain backwards compatibility - # while also giving the user-facing API a more representative name - self.labels = self.rule.get('jira_labels', self.rule.get('jira_label')) - - self.description = self.rule.get('jira_description', '') - self.assignee = self.rule.get('jira_assignee') - self.max_age = self.rule.get('jira_max_age', 30) - self.priority = self.rule.get('jira_priority') - self.bump_tickets = self.rule.get('jira_bump_tickets', False) - self.bump_not_in_statuses = self.rule.get('jira_bump_not_in_statuses') - self.bump_in_statuses = self.rule.get('jira_bump_in_statuses') - self.bump_after_inactivity = self.rule.get('jira_bump_after_inactivity', 0) - self.bump_only = self.rule.get('jira_bump_only', False) - self.transition = self.rule.get('jira_transition_to', False) - self.watchers = self.rule.get('jira_watchers') - self.client = None - - if self.bump_in_statuses and self.bump_not_in_statuses: - msg = 'Both jira_bump_in_statuses (%s) and jira_bump_not_in_statuses (%s) are set.' % \ - (','.join(self.bump_in_statuses), ','.join(self.bump_not_in_statuses)) - intersection = list(set(self.bump_in_statuses) & set(self.bump_in_statuses)) - if intersection: - msg = '%s Both have common statuses of (%s). As such, no tickets will ever be found.' % ( - msg, ','.join(intersection)) - msg += ' This should be simplified to use only one or the other.' - elastalert_logger.warning(msg) - - self.reset_jira_args() - - try: - self.client = JIRA(self.server, basic_auth=(self.user, self.password)) - self.get_priorities() - self.jira_fields = self.client.fields() - self.get_arbitrary_fields() - except JIRAError as e: - # JIRAError may contain HTML, pass along only first 1024 chars - raise EAException("Error connecting to JIRA: %s" % (str(e)[:1024])).with_traceback(sys.exc_info()[2]) - - self.set_priority() - - def set_priority(self): - try: - if self.priority is not None and self.client is not None: - self.jira_args['priority'] = {'id': self.priority_ids[self.priority]} - except KeyError: - elastalert_logger.error("Priority %s not found. Valid priorities are %s" % (self.priority, list(self.priority_ids.keys()))) - - def reset_jira_args(self): - self.jira_args = {'project': {'key': self.project}, - 'issuetype': {'name': self.issue_type}} - - if self.components: - # Support single component or list - if type(self.components) != list: - self.jira_args['components'] = [{'name': self.components}] - else: - self.jira_args['components'] = [{'name': component} for component in self.components] - if self.labels: - # Support single label or list - if type(self.labels) != list: - self.labels = [self.labels] - self.jira_args['labels'] = self.labels - if self.watchers: - # Support single watcher or list - if type(self.watchers) != list: - self.watchers = [self.watchers] - if self.assignee: - self.jira_args['assignee'] = {'name': self.assignee} - - self.set_priority() - - def set_jira_arg(self, jira_field, value, fields): - # Remove the jira_ part. Convert underscores to spaces - normalized_jira_field = jira_field[5:].replace('_', ' ').lower() - # All jira fields should be found in the 'id' or the 'name' field. Therefore, try both just in case - for identifier in ['name', 'id']: - field = next((f for f in fields if normalized_jira_field == f[identifier].replace('_', ' ').lower()), None) - if field: - break - if not field: - # Log a warning to ElastAlert saying that we couldn't find that type? - # OR raise and fail to load the alert entirely? Probably the latter... - raise Exception("Could not find a definition for the jira field '{0}'".format(normalized_jira_field)) - arg_name = field['id'] - # Check the schema information to decide how to set the value correctly - # If the schema information is not available, raise an exception since we don't know how to set it - # Note this is only the case for two built-in types, id: issuekey and id: thumbnail - if not ('schema' in field or 'type' in field['schema']): - raise Exception("Could not determine schema information for the jira field '{0}'".format(normalized_jira_field)) - arg_type = field['schema']['type'] - - # Handle arrays of simple types like strings or numbers - if arg_type == 'array': - # As a convenience, support the scenario wherein the user only provides - # a single value for a multi-value field e.g. jira_labels: Only_One_Label - if type(value) != list: - value = [value] - array_items = field['schema']['items'] - # Simple string types - if array_items in ['string', 'date', 'datetime']: - # Special case for multi-select custom types (the JIRA metadata says that these are strings, but - # in reality, they are required to be provided as an object. - if 'custom' in field['schema'] and field['schema']['custom'] in self.custom_string_types_with_special_handling: - self.jira_args[arg_name] = [{'value': v} for v in value] - else: - self.jira_args[arg_name] = value - elif array_items == 'number': - self.jira_args[arg_name] = [int(v) for v in value] - # Also attempt to handle arrays of complex types that have to be passed as objects with an identifier 'key' - elif array_items == 'option': - self.jira_args[arg_name] = [{'value': v} for v in value] - else: - # Try setting it as an object, using 'name' as the key - # This may not work, as the key might actually be 'key', 'id', 'value', or something else - # If it works, great! If not, it will manifest itself as an API error that will bubble up - self.jira_args[arg_name] = [{'name': v} for v in value] - # Handle non-array types - else: - # Simple string types - if arg_type in ['string', 'date', 'datetime']: - # Special case for custom types (the JIRA metadata says that these are strings, but - # in reality, they are required to be provided as an object. - if 'custom' in field['schema'] and field['schema']['custom'] in self.custom_string_types_with_special_handling: - self.jira_args[arg_name] = {'value': value} - else: - self.jira_args[arg_name] = value - # Number type - elif arg_type == 'number': - self.jira_args[arg_name] = int(value) - elif arg_type == 'option': - self.jira_args[arg_name] = {'value': value} - # Complex type - else: - self.jira_args[arg_name] = {'name': value} - - def get_arbitrary_fields(self): - # Clear jira_args - self.reset_jira_args() - - for jira_field, value in self.rule.items(): - # If we find a field that is not covered by the set that we are aware of, it means it is either: - # 1. A built-in supported field in JIRA that we don't have on our radar - # 2. A custom field that a JIRA admin has configured - if jira_field.startswith('jira_') and jira_field not in self.known_field_list and str(value)[:1] != '#': - self.set_jira_arg(jira_field, value, self.jira_fields) - if jira_field.startswith('jira_') and jira_field not in self.known_field_list and str(value)[:1] == '#': - self.deferred_settings.append(jira_field) - - def get_priorities(self): - """ Creates a mapping of priority index to id. """ - priorities = self.client.priorities() - self.priority_ids = {} - for x in range(len(priorities)): - self.priority_ids[x] = priorities[x].id - - def set_assignee(self, assignee): - self.assignee = assignee - if assignee: - self.jira_args['assignee'] = {'name': assignee} - elif 'assignee' in self.jira_args: - self.jira_args.pop('assignee') - - def find_existing_ticket(self, matches): - # Default title, get stripped search version - if 'alert_subject' not in self.rule: - title = self.create_default_title(matches, True) - else: - title = self.create_title(matches) - - if 'jira_ignore_in_title' in self.rule: - title = title.replace(matches[0].get(self.rule['jira_ignore_in_title'], ''), '') - - # This is necessary for search to work. Other special characters and dashes - # directly adjacent to words appear to be ok - title = title.replace(' - ', ' ') - title = title.replace('\\', '\\\\') - - date = (datetime.datetime.now() - datetime.timedelta(days=self.max_age)).strftime('%Y-%m-%d') - jql = 'project=%s AND summary~"%s" and created >= "%s"' % (self.project, title, date) - if self.bump_in_statuses: - jql = '%s and status in (%s)' % (jql, ','.join(["\"%s\"" % status if ' ' in status else status for status - in self.bump_in_statuses])) - if self.bump_not_in_statuses: - jql = '%s and status not in (%s)' % (jql, ','.join(["\"%s\"" % status if ' ' in status else status - for status in self.bump_not_in_statuses])) - try: - issues = self.client.search_issues(jql) - except JIRAError as e: - elastalert_logger.exception("Error while searching for JIRA ticket using jql '%s': %s" % (jql, e)) - return None - - if len(issues): - return issues[0] - - def comment_on_ticket(self, ticket, match): - text = str(JiraFormattedMatchString(self.rule, match)) - timestamp = pretty_ts(lookup_es_key(match, self.rule['timestamp_field'])) - comment = "This alert was triggered again at %s\n%s" % (timestamp, text) - self.client.add_comment(ticket, comment) - - def transition_ticket(self, ticket): - transitions = self.client.transitions(ticket) - for t in transitions: - if t['name'] == self.transition: - self.client.transition_issue(ticket, t['id']) - - def alert(self, matches): - # Reset arbitrary fields to pick up changes - self.get_arbitrary_fields() - if len(self.deferred_settings) > 0: - fields = self.client.fields() - for jira_field in self.deferred_settings: - value = lookup_es_key(matches[0], self.rule[jira_field][1:]) - self.set_jira_arg(jira_field, value, fields) - - title = self.create_title(matches) - - if self.bump_tickets: - ticket = self.find_existing_ticket(matches) - if ticket: - inactivity_datetime = ts_now() - datetime.timedelta(days=self.bump_after_inactivity) - if ts_to_dt(ticket.fields.updated) >= inactivity_datetime: - if self.pipeline is not None: - self.pipeline['jira_ticket'] = None - self.pipeline['jira_server'] = self.server - return None - elastalert_logger.info('Commenting on existing ticket %s' % (ticket.key)) - for match in matches: - try: - self.comment_on_ticket(ticket, match) - except JIRAError as e: - elastalert_logger.exception("Error while commenting on ticket %s: %s" % (ticket, e)) - if self.labels: - for label in self.labels: - try: - ticket.fields.labels.append(label) - except JIRAError as e: - elastalert_logger.exception("Error while appending labels to ticket %s: %s" % (ticket, e)) - if self.transition: - elastalert_logger.info('Transitioning existing ticket %s' % (ticket.key)) - try: - self.transition_ticket(ticket) - except JIRAError as e: - elastalert_logger.exception("Error while transitioning ticket %s: %s" % (ticket, e)) - - if self.pipeline is not None: - self.pipeline['jira_ticket'] = ticket - self.pipeline['jira_server'] = self.server - return None - if self.bump_only: - return None - - self.jira_args['summary'] = title - self.jira_args['description'] = self.create_alert_body(matches) - - try: - self.issue = self.client.create_issue(**self.jira_args) - - # You can not add watchers on initial creation. Only as a follow-up action - if self.watchers: - for watcher in self.watchers: - try: - self.client.add_watcher(self.issue.key, watcher) - except Exception as ex: - # Re-raise the exception, preserve the stack-trace, and give some - # context as to which watcher failed to be added - raise Exception( - "Exception encountered when trying to add '{0}' as a watcher. Does the user exist?\n{1}" .format( - watcher, - ex - )).with_traceback(sys.exc_info()[2]) - - except JIRAError as e: - raise EAException("Error creating JIRA ticket using jira_args (%s): %s" % (self.jira_args, e)) - elastalert_logger.info("Opened Jira ticket: %s" % (self.issue)) - - if self.pipeline is not None: - self.pipeline['jira_ticket'] = self.issue - self.pipeline['jira_server'] = self.server - - def create_alert_body(self, matches): - body = self.description + '\n' - body += self.get_aggregation_summary_text(matches) - if self.rule.get('alert_text_type') != 'aggregation_summary_only': - for match in matches: - body += str(JiraFormattedMatchString(self.rule, match)) - if len(matches) > 1: - body += '\n----------------------------------------\n' - return body - - def get_aggregation_summary_text(self, matches): - text = super(JiraAlerter, self).get_aggregation_summary_text(matches) - if text: - text = '{{noformat}}{0}{{noformat}}'.format(text) - return text - - def create_default_title(self, matches, for_search=False): - # If there is a query_key, use that in the title - - if 'query_key' in self.rule and lookup_es_key(matches[0], self.rule['query_key']): - title = 'ElastAlert: %s matched %s' % (lookup_es_key(matches[0], self.rule['query_key']), self.rule['name']) - else: - title = 'ElastAlert: %s' % (self.rule['name']) - - if for_search: - return title - - timestamp = matches[0].get(self.rule['timestamp_field']) - if timestamp: - title += ' - %s' % (pretty_ts(timestamp, self.rule.get('use_local_time'))) - - # Add count for spikes - count = matches[0].get('spike_count') - if count: - title += ' - %s+ events' % (count) - - return title - - def get_info(self): - return {'type': 'jira'} - - class CommandAlerter(Alerter): required_options = set(['command']) diff --git a/elastalert/loaders.py b/elastalert/loaders.py index 5a336c0c..0ae3e9d0 100644 --- a/elastalert/loaders.py +++ b/elastalert/loaders.py @@ -16,6 +16,7 @@ from . import enhancements from . import ruletypes from .alerters.email import EmailAlerter +from .alerters.jira import JiraAlerter from .alerters.opsgenie import OpsGenieAlerter from .alerters.zabbix import ZabbixAlerter from .util import dt_to_ts @@ -61,7 +62,7 @@ class RulesLoader(object): # Used to map names of alerts to their classes alerts_mapping = { 'email': EmailAlerter, - 'jira': alerts.JiraAlerter, + 'jira': JiraAlerter, 'opsgenie': OpsGenieAlerter, 'stomp': alerts.StompAlerter, 'debug': alerts.DebugAlerter, diff --git a/tests/alerts_test.py b/tests/alerts_test.py index db6eba7d..69f85c0c 100644 --- a/tests/alerts_test.py +++ b/tests/alerts_test.py @@ -24,8 +24,6 @@ from elastalert.alerts import GoogleChatAlerter from elastalert.alerts import HiveAlerter from elastalert.alerts import HTTPPostAlerter -from elastalert.alerts import JiraAlerter -from elastalert.alerts import JiraFormattedMatchString from elastalert.alerts import LineNotifyAlerter from elastalert.alerts import MattermostAlerter from elastalert.alerts import MsTeamsAlerter @@ -35,6 +33,8 @@ from elastalert.alerts import SlackAlerter from elastalert.alerts import TelegramAlerter from elastalert.loaders import FileRulesLoader +from elastalert.alerters.jira import JiraAlerter +from elastalert.alerters.jira import JiraFormattedMatchString from elastalert.alerters.email import EmailAlerter from elastalert.alerters.opsgenie import OpsGenieAlerter from elastalert.alerters.zabbix import ZabbixAlerter @@ -1279,7 +1279,7 @@ def test_jira(): mock_priority = mock.Mock(id='5') - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value.priorities.return_value = [mock_priority] @@ -1310,7 +1310,7 @@ def test_jira(): # Search called if jira_bump_tickets rule['jira_bump_tickets'] = True - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value = mock.Mock() @@ -1326,7 +1326,7 @@ def test_jira(): # Remove a field if jira_ignore_in_title set rule['jira_ignore_in_title'] = 'test_term' - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value = mock.Mock() @@ -1340,7 +1340,7 @@ def test_jira(): assert 'test_value' not in mock_jira.mock_calls[3][1][0] # Issue is still created if search_issues throws an exception - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value = mock.Mock() @@ -1359,7 +1359,7 @@ def test_jira(): # Check ticket is bumped if it is updated 4 days ago mock_issue.fields.updated = str(ts_now() - datetime.timedelta(days=4)) - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value = mock.Mock() @@ -1375,7 +1375,7 @@ def test_jira(): # Check ticket is bumped is not bumped if ticket is updated right now mock_issue.fields.updated = str(ts_now()) - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value = mock.Mock() @@ -1410,7 +1410,7 @@ def test_jira(): mock_fields = [ {'name': 'affected user', 'id': 'affected_user_id', 'schema': {'type': 'string'}} ] - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value = mock.Mock() @@ -1483,7 +1483,7 @@ def test_jira_arbitrary_field_support(): }, ] - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value.priorities.return_value = [mock_priority] @@ -1524,7 +1524,7 @@ def test_jira_arbitrary_field_support(): # Reference an arbitrary string field that is not defined on the JIRA server rule['jira_nonexistent_field'] = 'nonexistent field value' - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value.priorities.return_value = [mock_priority] @@ -1540,7 +1540,7 @@ def test_jira_arbitrary_field_support(): # Reference a watcher that does not exist rule['jira_watchers'] = 'invalid_watcher' - with mock.patch('elastalert.alerts.JIRA') as mock_jira, \ + with mock.patch('elastalert.alerters.jira.JIRA') as mock_jira, \ mock.patch('elastalert.alerts.read_yaml') as mock_open: mock_open.return_value = {'user': 'jirauser', 'password': 'jirapassword'} mock_jira.return_value.priorities.return_value = [mock_priority]