491 lines
24 KiB
Python
491 lines
24 KiB
Python
# Copyright (c) 2016 Ansible, Inc.
|
|
# All Rights Reserved.
|
|
|
|
from copy import deepcopy
|
|
import datetime
|
|
import logging
|
|
import json
|
|
|
|
from django.db import models
|
|
from django.conf import settings
|
|
from django.core.mail.message import EmailMessage
|
|
from django.db import connection
|
|
from django.utils.translation import ugettext_lazy as _
|
|
from django.utils.encoding import smart_str, force_text
|
|
from jinja2 import sandbox, ChainableUndefined
|
|
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
|
|
|
|
# AWX
|
|
from awx.api.versioning import reverse
|
|
from awx.main.models.base import CommonModelNameNotUnique, CreatedModifiedModel, prevent_search
|
|
from awx.main.utils import encrypt_field, decrypt_field, set_environ
|
|
from awx.main.notifications.email_backend import CustomEmailBackend
|
|
from awx.main.notifications.slack_backend import SlackBackend
|
|
from awx.main.notifications.twilio_backend import TwilioBackend
|
|
from awx.main.notifications.pagerduty_backend import PagerDutyBackend
|
|
from awx.main.notifications.webhook_backend import WebhookBackend
|
|
from awx.main.notifications.mattermost_backend import MattermostBackend
|
|
from awx.main.notifications.grafana_backend import GrafanaBackend
|
|
from awx.main.notifications.rocketchat_backend import RocketChatBackend
|
|
from awx.main.notifications.irc_backend import IrcBackend
|
|
from awx.main.fields import JSONField
|
|
|
|
|
|
logger = logging.getLogger('awx.main.models.notifications')
|
|
|
|
__all__ = ['NotificationTemplate', 'Notification']
|
|
|
|
|
|
class NotificationTemplate(CommonModelNameNotUnique):
|
|
|
|
NOTIFICATION_TYPES = [('email', _('Email'), CustomEmailBackend),
|
|
('slack', _('Slack'), SlackBackend),
|
|
('twilio', _('Twilio'), TwilioBackend),
|
|
('pagerduty', _('Pagerduty'), PagerDutyBackend),
|
|
('grafana', _('Grafana'), GrafanaBackend),
|
|
('webhook', _('Webhook'), WebhookBackend),
|
|
('mattermost', _('Mattermost'), MattermostBackend),
|
|
('rocketchat', _('Rocket.Chat'), RocketChatBackend),
|
|
('irc', _('IRC'), IrcBackend)]
|
|
NOTIFICATION_TYPE_CHOICES = sorted([(x[0], x[1]) for x in NOTIFICATION_TYPES])
|
|
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NOTIFICATION_TYPES])
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
unique_together = ('organization', 'name')
|
|
ordering = ("name",)
|
|
|
|
organization = models.ForeignKey(
|
|
'Organization',
|
|
blank=False,
|
|
null=True,
|
|
on_delete=models.CASCADE,
|
|
related_name='notification_templates',
|
|
)
|
|
|
|
notification_type = models.CharField(
|
|
max_length = 32,
|
|
choices=NOTIFICATION_TYPE_CHOICES,
|
|
)
|
|
|
|
notification_configuration = prevent_search(JSONField(blank=False))
|
|
|
|
def default_messages():
|
|
return {'started': None, 'success': None, 'error': None, 'workflow_approval': None}
|
|
|
|
messages = JSONField(
|
|
null=True,
|
|
blank=True,
|
|
default=default_messages,
|
|
help_text=_('Optional custom messages for notification template.'))
|
|
|
|
def has_message(self, condition):
|
|
potential_template = self.messages.get(condition, {})
|
|
if potential_template == {}:
|
|
return False
|
|
if potential_template.get('message', {}) == {}:
|
|
return False
|
|
return True
|
|
|
|
def get_message(self, condition):
|
|
return self.messages.get(condition, {})
|
|
|
|
def get_absolute_url(self, request=None):
|
|
return reverse('api:notification_template_detail', kwargs={'pk': self.pk}, request=request)
|
|
|
|
@property
|
|
def notification_class(self):
|
|
return self.CLASS_FOR_NOTIFICATION_TYPE[self.notification_type]
|
|
|
|
def save(self, *args, **kwargs):
|
|
new_instance = not bool(self.pk)
|
|
update_fields = kwargs.get('update_fields', [])
|
|
|
|
# preserve existing notification messages if not overwritten by new messages
|
|
if not new_instance:
|
|
old_nt = NotificationTemplate.objects.get(pk=self.id)
|
|
old_messages = old_nt.messages
|
|
new_messages = self.messages
|
|
|
|
def merge_messages(local_old_messages, local_new_messages, local_event):
|
|
if local_new_messages.get(local_event, {}) and local_old_messages.get(local_event, {}):
|
|
local_old_event_msgs = local_old_messages[local_event]
|
|
local_new_event_msgs = local_new_messages[local_event]
|
|
for msg_type in ['message', 'body']:
|
|
if msg_type not in local_new_event_msgs and local_old_event_msgs.get(msg_type, None):
|
|
local_new_event_msgs[msg_type] = local_old_event_msgs[msg_type]
|
|
if old_messages is not None and new_messages is not None:
|
|
for event in ('started', 'success', 'error', 'workflow_approval'):
|
|
if not new_messages.get(event, {}) and old_messages.get(event, {}):
|
|
new_messages[event] = old_messages[event]
|
|
continue
|
|
|
|
if event == 'workflow_approval' and old_messages.get('workflow_approval', None):
|
|
new_messages.setdefault('workflow_approval', {})
|
|
for subevent in ('running', 'approved', 'timed_out', 'denied'):
|
|
old_wfa_messages = old_messages['workflow_approval']
|
|
new_wfa_messages = new_messages['workflow_approval']
|
|
if not new_wfa_messages.get(subevent, {}) and old_wfa_messages.get(subevent, {}):
|
|
new_wfa_messages[subevent] = old_wfa_messages[subevent]
|
|
continue
|
|
if old_wfa_messages:
|
|
merge_messages(old_wfa_messages, new_wfa_messages, subevent)
|
|
else:
|
|
merge_messages(old_messages, new_messages, event)
|
|
new_messages.setdefault(event, None)
|
|
|
|
|
|
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
|
|
self.notification_class.init_parameters):
|
|
if self.notification_configuration[field].startswith("$encrypted$"):
|
|
continue
|
|
if new_instance:
|
|
value = self.notification_configuration[field]
|
|
setattr(self, '_saved_{}_{}'.format("config", field), value)
|
|
self.notification_configuration[field] = ''
|
|
else:
|
|
encrypted = encrypt_field(self, 'notification_configuration', subfield=field)
|
|
self.notification_configuration[field] = encrypted
|
|
if 'notification_configuration' not in update_fields:
|
|
update_fields.append('notification_configuration')
|
|
super(NotificationTemplate, self).save(*args, **kwargs)
|
|
if new_instance:
|
|
update_fields = []
|
|
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
|
|
self.notification_class.init_parameters):
|
|
saved_value = getattr(self, '_saved_{}_{}'.format("config", field), '')
|
|
self.notification_configuration[field] = saved_value
|
|
if 'notification_configuration' not in update_fields:
|
|
update_fields.append('notification_configuration')
|
|
self.save(update_fields=update_fields)
|
|
|
|
@property
|
|
def recipients(self):
|
|
return self.notification_configuration[self.notification_class.recipient_parameter]
|
|
|
|
def generate_notification(self, msg, body):
|
|
notification = Notification(notification_template=self,
|
|
notification_type=self.notification_type,
|
|
recipients=smart_str(self.recipients),
|
|
subject=msg,
|
|
body=body)
|
|
notification.save()
|
|
return notification
|
|
|
|
def send(self, subject, body):
|
|
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
|
|
self.notification_class.init_parameters):
|
|
if field in self.notification_configuration:
|
|
self.notification_configuration[field] = decrypt_field(self,
|
|
'notification_configuration',
|
|
subfield=field)
|
|
recipients = self.notification_configuration.pop(self.notification_class.recipient_parameter)
|
|
if not isinstance(recipients, list):
|
|
recipients = [recipients]
|
|
sender = self.notification_configuration.pop(self.notification_class.sender_parameter, None)
|
|
notification_configuration = deepcopy(self.notification_configuration)
|
|
for field, params in self.notification_class.init_parameters.items():
|
|
if field not in notification_configuration:
|
|
if 'default' in params:
|
|
notification_configuration[field] = params['default']
|
|
backend_obj = self.notification_class(**notification_configuration)
|
|
notification_obj = EmailMessage(subject, backend_obj.format_body(body), sender, recipients)
|
|
with set_environ(**settings.AWX_TASK_ENV):
|
|
return backend_obj.send_messages([notification_obj])
|
|
|
|
def display_notification_configuration(self):
|
|
field_val = self.notification_configuration.copy()
|
|
for field in self.notification_class.init_parameters:
|
|
if field in field_val and force_text(field_val[field]).startswith('$encrypted$'):
|
|
field_val[field] = '$encrypted$'
|
|
return field_val
|
|
|
|
|
|
class Notification(CreatedModifiedModel):
|
|
'''
|
|
A notification event emitted when a NotificationTemplate is run
|
|
'''
|
|
|
|
NOTIFICATION_STATE_CHOICES = [
|
|
('pending', _('Pending')),
|
|
('successful', _('Successful')),
|
|
('failed', _('Failed')),
|
|
]
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
ordering = ('pk',)
|
|
|
|
notification_template = models.ForeignKey(
|
|
'NotificationTemplate',
|
|
related_name='notifications',
|
|
on_delete=models.CASCADE,
|
|
editable=False
|
|
)
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=NOTIFICATION_STATE_CHOICES,
|
|
default='pending',
|
|
editable=False,
|
|
)
|
|
error = models.TextField(
|
|
blank=True,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
notifications_sent = models.IntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
notification_type = models.CharField(
|
|
max_length = 32,
|
|
choices=NotificationTemplate.NOTIFICATION_TYPE_CHOICES,
|
|
)
|
|
recipients = models.TextField(
|
|
blank=True,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
subject = models.TextField(
|
|
blank=True,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
body = JSONField(blank=True)
|
|
|
|
def get_absolute_url(self, request=None):
|
|
return reverse('api:notification_detail', kwargs={'pk': self.pk}, request=request)
|
|
|
|
|
|
class JobNotificationMixin(object):
|
|
STATUS_TO_TEMPLATE_TYPE = {'succeeded': 'success',
|
|
'running': 'started',
|
|
'failed': 'error'}
|
|
# Tree of fields that can be safely referenced in a notification message
|
|
JOB_FIELDS_ALLOWED_LIST = ['id', 'type', 'url', 'created', 'modified', 'name', 'description', 'job_type', 'playbook',
|
|
'forks', 'limit', 'verbosity', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task',
|
|
'timeout', 'use_fact_cache', 'launch_type', 'status', 'failed', 'started', 'finished',
|
|
'elapsed', 'job_explanation', 'execution_node', 'controller_node', 'allow_simultaneous',
|
|
'scm_revision', 'diff_mode', 'job_slice_number', 'job_slice_count', 'custom_virtualenv',
|
|
'approval_status', 'approval_node_name', 'workflow_url', 'scm_branch', 'artifacts',
|
|
{'host_status_counts': ['skipped', 'ok', 'changed', 'failed', 'failures', 'dark'
|
|
'processed', 'rescued', 'ignored']},
|
|
{'summary_fields': [{'inventory': ['id', 'name', 'description', 'has_active_failures',
|
|
'total_hosts', 'hosts_with_active_failures', 'total_groups',
|
|
'has_inventory_sources',
|
|
'total_inventory_sources', 'inventory_sources_with_failures',
|
|
'organization_id', 'kind']},
|
|
{'project': ['id', 'name', 'description', 'status', 'scm_type']},
|
|
{'job_template': ['id', 'name', 'description']},
|
|
{'unified_job_template': ['id', 'name', 'description', 'unified_job_type']},
|
|
{'instance_group': ['name', 'id']},
|
|
{'created_by': ['id', 'username', 'first_name', 'last_name']},
|
|
{'labels': ['count', 'results']}]}]
|
|
|
|
@classmethod
|
|
def context_stub(cls):
|
|
"""Returns a stub context that can be used for validating notification messages.
|
|
Context has the same structure as the context that will actually be used to render
|
|
a notification message."""
|
|
context = {'job': {'allow_simultaneous': False,
|
|
'artifacts': {},
|
|
'controller_node': 'foo_controller',
|
|
'created': datetime.datetime(2018, 11, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc),
|
|
'custom_virtualenv': 'my_venv',
|
|
'description': 'Sample job description',
|
|
'diff_mode': False,
|
|
'elapsed': 0.403018,
|
|
'execution_node': 'awx',
|
|
'failed': False,
|
|
'finished': False,
|
|
'force_handlers': False,
|
|
'forks': 0,
|
|
'host_status_counts': {'skipped': 1, 'ok': 5, 'changed': 3, 'failures': 0, 'dark': 0, 'failed': False, 'processed': 0, 'rescued': 0},
|
|
'id': 42,
|
|
'job_explanation': 'Sample job explanation',
|
|
'job_slice_count': 1,
|
|
'job_slice_number': 0,
|
|
'job_tags': '',
|
|
'job_type': 'run',
|
|
'launch_type': 'workflow',
|
|
'limit': 'bar_limit',
|
|
'modified': datetime.datetime(2018, 12, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc),
|
|
'name': 'Stub JobTemplate',
|
|
'playbook': 'ping.yml',
|
|
'scm_branch': '',
|
|
'scm_revision': '',
|
|
'skip_tags': '',
|
|
'start_at_task': '',
|
|
'started': '2019-07-29T17:38:14.137461Z',
|
|
'status': 'running',
|
|
'summary_fields': {'created_by': {'first_name': '',
|
|
'id': 1,
|
|
'last_name': '',
|
|
'username': 'admin'},
|
|
'instance_group': {'id': 1, 'name': 'tower'},
|
|
'inventory': {'description': 'Sample inventory description',
|
|
'has_active_failures': False,
|
|
'has_inventory_sources': False,
|
|
'hosts_with_active_failures': 0,
|
|
'id': 17,
|
|
'inventory_sources_with_failures': 0,
|
|
'kind': '',
|
|
'name': 'Stub Inventory',
|
|
'organization_id': 121,
|
|
'total_groups': 0,
|
|
'total_hosts': 1,
|
|
'total_inventory_sources': 0},
|
|
'job_template': {'description': 'Sample job template description',
|
|
'id': 39,
|
|
'name': 'Stub JobTemplate'},
|
|
'labels': {'count': 0, 'results': []},
|
|
'project': {'description': 'Sample project description',
|
|
'id': 38,
|
|
'name': 'Stub project',
|
|
'scm_type': 'git',
|
|
'status': 'successful'},
|
|
'unified_job_template': {'description': 'Sample unified job template description',
|
|
'id': 39,
|
|
'name': 'Stub Job Template',
|
|
'unified_job_type': 'job'}},
|
|
'timeout': 0,
|
|
'type': 'job',
|
|
'url': '/api/v2/jobs/13/',
|
|
'use_fact_cache': False,
|
|
'verbosity': 0},
|
|
'job_friendly_name': 'Job',
|
|
'url': 'https://towerhost/#/jobs/playbook/1010',
|
|
'approval_status': 'approved',
|
|
'approval_node_name': 'Approve Me',
|
|
'workflow_url': 'https://towerhost/#/jobs/workflow/1010',
|
|
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
|
|
'traceback': '',
|
|
'status': 'running',
|
|
'started': '2019-08-07T21:46:38.362630+00:00',
|
|
'project': 'Stub project',
|
|
'playbook': 'ping.yml',
|
|
'name': 'Stub Job Template',
|
|
'limit': '',
|
|
'inventory': 'Stub Inventory',
|
|
'id': 42,
|
|
'hosts': {},
|
|
'friendly_name': 'Job',
|
|
'finished': False,
|
|
'credential': 'Stub credential',
|
|
'created_by': 'admin'}"""}
|
|
|
|
return context
|
|
|
|
def context(self, serialized_job):
|
|
"""Returns a dictionary that can be used for rendering notification messages.
|
|
The context will contain allowed content retrieved from a serialized job object
|
|
(see JobNotificationMixin.JOB_FIELDS_ALLOWED_LIST the job's friendly name,
|
|
and a url to the job run."""
|
|
job_context = {'host_status_counts': {}}
|
|
summary = None
|
|
if hasattr(self, 'job_host_summaries'):
|
|
summary = self.job_host_summaries.first()
|
|
if summary:
|
|
from awx.api.serializers import JobHostSummarySerializer
|
|
summary_data = JobHostSummarySerializer(summary).to_representation(summary)
|
|
job_context['host_status_counts'] = summary_data
|
|
context = {
|
|
'job': job_context,
|
|
'job_friendly_name': self.get_notification_friendly_name(),
|
|
'url': self.get_ui_url(),
|
|
'job_metadata': json.dumps(
|
|
self.notification_data(),
|
|
ensure_ascii=False,
|
|
indent=4
|
|
)
|
|
}
|
|
|
|
def build_context(node, fields, allowed_fields):
|
|
for safe_field in allowed_fields:
|
|
if type(safe_field) is dict:
|
|
field, allowed_subnode = safe_field.copy().popitem()
|
|
# ensure content present in job serialization
|
|
if field not in fields:
|
|
continue
|
|
subnode = fields[field]
|
|
node[field] = {}
|
|
build_context(node[field], subnode, allowed_subnode)
|
|
else:
|
|
# ensure content present in job serialization
|
|
if safe_field not in fields:
|
|
continue
|
|
node[safe_field] = fields[safe_field]
|
|
build_context(context['job'], serialized_job, self.JOB_FIELDS_ALLOWED_LIST)
|
|
|
|
return context
|
|
|
|
def get_notification_templates(self):
|
|
raise RuntimeError("Define me")
|
|
|
|
def get_notification_friendly_name(self):
|
|
raise RuntimeError("Define me")
|
|
|
|
def notification_data(self):
|
|
raise RuntimeError("Define me")
|
|
|
|
def build_notification_message(self, nt, status):
|
|
env = sandbox.ImmutableSandboxedEnvironment(undefined=ChainableUndefined)
|
|
|
|
from awx.api.serializers import UnifiedJobSerializer
|
|
job_serialization = UnifiedJobSerializer(self).to_representation(self)
|
|
context = self.context(job_serialization)
|
|
|
|
msg_template = body_template = None
|
|
msg = body = ''
|
|
|
|
# Use custom template if available
|
|
if nt.messages:
|
|
template = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {}
|
|
msg_template = template.get('message', None)
|
|
body_template = template.get('body', None)
|
|
# If custom template not provided, look up default template
|
|
default_template = nt.notification_class.default_messages[self.STATUS_TO_TEMPLATE_TYPE[status]]
|
|
if not msg_template:
|
|
msg_template = default_template.get('message', None)
|
|
if not body_template:
|
|
body_template = default_template.get('body', None)
|
|
|
|
if msg_template:
|
|
try:
|
|
msg = env.from_string(msg_template).render(**context)
|
|
except (TemplateSyntaxError, UndefinedError, SecurityError):
|
|
msg = ''
|
|
|
|
if body_template:
|
|
try:
|
|
body = env.from_string(body_template).render(**context)
|
|
except (TemplateSyntaxError, UndefinedError, SecurityError):
|
|
body = ''
|
|
|
|
return (msg, body)
|
|
|
|
def send_notification_templates(self, status):
|
|
from awx.main.tasks import send_notifications # avoid circular import
|
|
if status not in ['running', 'succeeded', 'failed']:
|
|
raise ValueError(_("status must be either running, succeeded or failed"))
|
|
try:
|
|
notification_templates = self.get_notification_templates()
|
|
except Exception:
|
|
logger.warn("No notification template defined for emitting notification")
|
|
return
|
|
|
|
if not notification_templates:
|
|
return
|
|
|
|
for nt in set(notification_templates.get(self.STATUS_TO_TEMPLATE_TYPE[status], [])):
|
|
(msg, body) = self.build_notification_message(nt, status)
|
|
|
|
# Use kwargs to force late-binding
|
|
# https://stackoverflow.com/a/3431699/10669572
|
|
def send_it(local_nt=nt, local_msg=msg, local_body=body):
|
|
def _func():
|
|
send_notifications.delay([local_nt.generate_notification(local_msg, local_body).id],
|
|
job_id=self.id)
|
|
return _func
|
|
connection.on_commit(send_it())
|