Skip to content

Commit

Permalink
basic email alerting
Browse files Browse the repository at this point in the history
  • Loading branch information
ansibleguy committed May 11, 2024
1 parent d856f25 commit 4f5dd27
Show file tree
Hide file tree
Showing 19 changed files with 307 additions and 70 deletions.
37 changes: 19 additions & 18 deletions src/ansibleguy-webui/aw/api_endpoints/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse

from aw.api_endpoints.base import API_PERMISSION, GenericResponse, get_api_user, api_docs_put, api_docs_delete, \
api_docs_post
api_docs_post, BaseResponse
from aw.utils.permission import has_manager_privileges
from aw.model.alert import AlertPlugin, AlertGlobal, AlertGroup, AlertUser


class BaseAlertWriteRequest(BaseResponse):
# NOTE: not using modelserializer because issues with DRF and PUT unique constraints
name = serializers.CharField(required=True)
alert_type = serializers.IntegerField()
condition = serializers.IntegerField()
jobs = serializers.ListSerializer(child=serializers.IntegerField(), required=False)
jobs_all = serializers.BooleanField()
# todo: require alert to be provided if alert-type is plugin
plugin = serializers.IntegerField(required=False)


class AlertPluginReadWrite(serializers.ModelSerializer):
class Meta:
model = AlertPlugin
Expand Down Expand Up @@ -170,10 +181,8 @@ class Meta:
condition_name = serializers.CharField()


class AlertUserWriteRequest(serializers.ModelSerializer):
class Meta:
model = AlertUser
fields = AlertUser.api_fields_write
class AlertUserWriteRequest(BaseAlertWriteRequest):
user = serializers.IntegerField(required=True)


class APIAlertUser(GenericAPIView):
Expand Down Expand Up @@ -324,12 +333,6 @@ class Meta:
condition_name = serializers.CharField()


class AlertGlobalWriteRequest(serializers.ModelSerializer):
class Meta:
model = AlertGlobal
fields = AlertGlobal.api_fields_write


class APIAlertGlobal(GenericAPIView):
http_method_names = ['get', 'post']
serializer_class = AlertGlobalReadResponse
Expand Down Expand Up @@ -360,7 +363,7 @@ def post(self, request):
status=403,
)

serializer = AlertGlobalWriteRequest(data=request.data)
serializer = BaseAlertWriteRequest(data=request.data)

if not serializer.is_valid():
return Response(
Expand Down Expand Up @@ -408,7 +411,7 @@ def get(request, alert_id: int):
return Response(data={'msg': f"Alert with ID {alert_id} does not exist"}, status=404)

@extend_schema(
request=AlertGlobalWriteRequest,
request=BaseAlertWriteRequest,
responses=api_docs_put('Alert'),
summary='Modify an Alert.',
operation_id='alert_global_edit',
Expand All @@ -421,7 +424,7 @@ def put(self, request, alert_id: int):
status=403,
)

serializer = AlertGlobalWriteRequest(data=request.data)
serializer = BaseAlertWriteRequest(data=request.data)

if not serializer.is_valid():
return Response(
Expand Down Expand Up @@ -484,10 +487,8 @@ class Meta:
group_name = serializers.CharField()


class AlertGroupWriteRequest(serializers.ModelSerializer):
class Meta:
model = AlertGroup
fields = AlertGroup.api_fields_write
class AlertGroupWriteRequest(BaseAlertWriteRequest):
group = serializers.IntegerField(required=True)


class APIAlertGroup(GenericAPIView):
Expand Down
2 changes: 1 addition & 1 deletion src/ansibleguy-webui/aw/api_endpoints/job_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def get_job_execution_serialized(execution: JobExecution) -> dict:
serialized['job_name'] = execution.job.name
serialized['job_comment'] = execution.job.comment
serialized['user'] = execution.user.id if execution.user is not None else None
serialized['user_name'] = execution.user.username if execution.user is not None else 'Scheduled'
serialized['user_name'] = execution.user_name
serialized['time_start'] = execution.time_created_str
serialized['time_fin'] = None
serialized['failed'] = None
Expand Down
6 changes: 4 additions & 2 deletions src/ansibleguy-webui/aw/api_endpoints/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
class SystemConfigReadResponse(BaseResponse):
# todo: fix static fields.. duplicate logic in model

# SystemConfig.form_fields
# SystemConfig.api_fields_read
path_run = serializers.CharField()
path_play = serializers.CharField()
path_log = serializers.CharField()
Expand All @@ -38,6 +38,8 @@ class SystemConfigReadResponse(BaseResponse):
mail_server = serializers.CharField()
mail_transport = serializers.IntegerField()
mail_user = serializers.CharField()
mail_sender = serializers.CharField()
mail_ssl_verify = serializers.BooleanField()


class SystemConfigWriteRequest(serializers.ModelSerializer):
Expand All @@ -64,7 +66,7 @@ def get(request):
del request
merged_config = {'read_only_settings': SystemConfig.api_fields_read_only}

for field in SystemConfig.form_fields + merged_config['read_only_settings']:
for field in SystemConfig.api_fields_read + merged_config['read_only_settings']:
merged_config[field] = config[field]

merged_config['read_only_settings'] += SystemConfig.get_set_env_vars()
Expand Down
9 changes: 8 additions & 1 deletion src/ansibleguy-webui/aw/config/form_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
'ssl_file_key': 'SSL Private-Key',
'mail_server': 'Mail Server',
'mail_transport': 'Mail Transport',
'mail_ssl_verify': 'Mail SSL Verification',
'mail_sender': 'Mail Sender Address',
'mail_user': 'Mail Login Username',
'mail_pass': 'Mail Login Password',
}
Expand Down Expand Up @@ -211,7 +213,12 @@
'Documentation - Integrations</a>',
'global_environment_vars': 'Set environmental variables that will be added to every job execution. '
'Comma-separated list of key-value pairs. (VAR1=TEST1,VAR2=0)',
'mail_server': 'Mail Server to use for Alert Mails',
'mail_server': 'Mail Server to use for Alert Mails. Combination of server and port (default 25)',
'mail_ssl_verify': 'En- or disable SSL certificate verification. '
'If enabled - the certificate SAN has to contain the mail-server FQDN '
'and must be issued from a trusted CA',
'mail_sender': 'Mail Sender Address to use for Alert Mails',
'mail_transport': 'The default port mapping is: 25 = Unencrypted, 465 = SSL, 587 = StartTLS',
}
}
}
70 changes: 70 additions & 0 deletions src/ansibleguy-webui/aw/execute/alert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from django.db.models import Q

from aw.base import USERS
from aw.model.base import JOB_EXEC_STATUS_SUCCESS
from aw.model.job import Job, JobExecution, JobExecutionResultHost
from aw.utils.permission import has_job_permission, CHOICE_PERMISSION_READ
from aw.model.alert import BaseAlert, AlertUser, AlertGroup, AlertGlobal, \
ALERT_CONDITION_FAIL, ALERT_CONDITION_SUCCESS, ALERT_CONDITION_ALWAYS, \
ALERT_TYPE_PLUGIN
from aw.execute.alert_plugin.plugin_email import alert_plugin_email
from aw.execute.alert_plugin.plugin_wrapper import alert_plugin_wrapper


class Alert:
def __init__(self, job: Job, execution: JobExecution):
self.job = job
self.execution = execution
self.failed = execution.status != JOB_EXEC_STATUS_SUCCESS
self.privileged_users = []
for user in USERS.objects.all():
if has_job_permission(user=user, job=job, permission_needed=CHOICE_PERMISSION_READ):
self.privileged_users.append(user)

self.stats = {}
if execution.result is None:
for stats in JobExecutionResultHost.objects.filter(result=execution.result):
self.stats[stats['hostname']] = {
attr: getattr(stats, attr) for attr in JobExecutionResultHost.STATS
}

def _job_filter(self, model: type):
return model.objects.filter(Q(jobs=self.job) | Q(jobs_all=True))

def _condition_filter(self, alerts: list[BaseAlert]):
matching = []
for alert in alerts:
if alert.condition == ALERT_CONDITION_ALWAYS or \
(self.failed and alert.condition == ALERT_CONDITION_FAIL) or \
(not self.failed and alert.condition == ALERT_CONDITION_SUCCESS):
matching.append(alert)

return matching

def _route(self, alert: BaseAlert, user: USERS):
if alert.alert_type == ALERT_TYPE_PLUGIN:
alert_plugin_wrapper(alert=alert, user=user, stats=self.stats, execution=self.execution)

else:
alert_plugin_email(user=user, stats=self.stats, execution=self.execution)

def _global(self):
for alert in self._condition_filter(self._job_filter(AlertGlobal)):
for user in self.privileged_users:
self._route(alert=alert, user=user)

def _group(self):
for alert in self._condition_filter(self._job_filter(AlertGroup)):
for user in self.privileged_users:
if user.groups.filter(name=alert.group).exists():
self._route(alert=alert, user=user)

def _user(self):
for user in self.privileged_users:
for alert in self._condition_filter(self._job_filter(AlertUser).filter(user=user)):
self._route(alert=alert, user=user)

def go(self):
self._global()
self._group()
self._user()
104 changes: 104 additions & 0 deletions src/ansibleguy-webui/aw/execute/alert_plugin/plugin_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import ssl
from pathlib import Path
from smtplib import SMTP, SMTP_SSL, SMTPResponseException
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from json import dumps as json_dumps

from aw.base import USERS
from aw.utils.util import valid_email
from aw.utils.debug import log
from aw.config.main import config
from aw.model.job import JobExecution
from aw.settings import get_main_web_address
from aw.model.system import MAIL_TRANSPORT_TYPE_SSL, MAIL_TRANSPORT_TYPE_STARTTLS


def _email_send(server: SMTP, user: USERS, stats: list[dict], execution: JobExecution):
server.login(user=config['mail_user'], password=config['mail_pass'])
msg = MIMEMultipart('alternative')
msg['Subject'] = f"Ansible WebUI Alert - Job '{execution.job.name}' - {execution.status_name}"
msg['From'] = config['mail_sender']
msg['To'] = user.email

text = f"""
Job: {execution.job.name}
Status: {execution.status_name}
Executed by: {execution.user_name}
Start time: {execution.time_created_str}
"""

if execution.result is not None:
text += f"""
Finish time: {execution.result.time_fin_str}
Duration: {execution.result.time_duration_str}
"""

if execution.result.error is not None:
text += f"""
Short error message: '{execution.result.error.short}'
Long error message: '{execution.result.error.med}'
"""

for log_attr in JobExecution.log_file_fields:
file = getattr(execution, log_attr)
if Path(file).is_file():
text += f"""
{log_attr.replace('_', ' ').capitalize()}: {get_main_web_address()}{getattr(execution, log_attr + '_url')}
"""

if len(stats) > 0:
text += f"""
Raw stats:
{json_dumps(stats)}
"""

msg.attach(MIMEText(text, 'plain'))
# msg.attach(MIMEText(html, 'html'))

server.sendmail(
from_addr=config['mail_sender'],
to_addrs=user.email,
msg=msg.as_string()
)


def alert_plugin_email(user: USERS, stats: list[dict], execution: JobExecution):
if user.email.endswith('@localhost') or not valid_email(user.email):
log(msg=f"User has an invalid email address configured: {user.username} ({user.email})", level=3)
return

try:
server, port = config['mail_server'].split(':', 1)

except ValueError:
server = config['mail_server']
port = 25

try:
print(f"Alert user {user.username} via email ({server}:{port} => {user.email})")
ssl_context = ssl.create_default_context()
if config['mail_ssl_verify']:
ssl_context.check_hostname = True
ssl_context.verify_mode = ssl.CERT_REQUIRED

else:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

if config['mail_transport'] == MAIL_TRANSPORT_TYPE_SSL:
with SMTP_SSL(server, port, context=ssl_context) as server:
server.login(config['mail_user'], config['mail_pass'])
_email_send(server=server, user=user, stats=stats, execution=execution)

else:
with SMTP(server, port) as server:
if config['mail_transport'] == MAIL_TRANSPORT_TYPE_STARTTLS:
server.starttls(context=ssl_context)

_email_send(server=server, user=user, stats=stats, execution=execution)

except (SMTPResponseException, OSError) as e:
log(msg=f"Got error sending alert mail: {e}", level=2)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from aw.base import USERS
from aw.model.job import JobExecution, JobExecutionResultHost
from aw.model.alert import BaseAlert, AlertPlugin


def alert_plugin_wrapper(alert: BaseAlert, user: USERS, stats: [JobExecutionResultHost], execution: JobExecution):
# implement plugin interface
print(f"Alert user {user.username} via plugin {alert.plugin.executable}")
3 changes: 3 additions & 0 deletions src/ansibleguy-webui/aw/execute/play.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from aw.execute.play_util import runner_cleanup, runner_prep, parse_run_result, failure, runner_logs
from aw.execute.util import get_path_run, is_execution_status, job_logs
from aw.execute.repository import ExecuteRepository
from aw.execute.alert import Alert
from aw.utils.util import datetime_w_tz, is_null, timed_lru_cache # get_ansible_versions
from aw.utils.handlers import AnsibleConfigError
from aw.utils.debug import log
Expand Down Expand Up @@ -63,11 +64,13 @@ def _cancel_job() -> bool:
del runner

runner_cleanup(execution=execution, path_run=path_run, exec_repo=exec_repo)
Alert(job=job, execution=execution).go()

except (OSError, AnsibleConfigError) as err:
tb = traceback.format_exc(limit=1024)
failure(
execution=execution, exec_repo=exec_repo, path_run=path_run, result=result,
error_s=str(err), error_m=tb,
)
Alert(job=job, execution=execution).go()
raise
4 changes: 2 additions & 2 deletions src/ansibleguy-webui/aw/execute/play_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,6 @@ def failure(
result.failed = True
result.error = job_error
result.save()

execution.save()
runner_cleanup(execution=JobExecution ,path_run=path_run, exec_repo=exec_repo)

runner_cleanup(execution=execution, path_run=path_run, exec_repo=exec_repo)
3 changes: 2 additions & 1 deletion src/ansibleguy-webui/aw/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
(False, 'No')
)
DEFAULT_NONE = {'null': True, 'default': None, 'blank': True}
JOB_EXEC_STATUS_SUCCESS = 4
CHOICES_JOB_EXEC_STATUS = [
(0, 'Waiting'),
(1, 'Starting'),
(2, 'Running'),
(3, 'Failed'),
(4, 'Finished'),
(JOB_EXEC_STATUS_SUCCESS, 'Finished'),
(5, 'Stopping'),
(6, 'Stopped'),
]
Expand Down
4 changes: 4 additions & 0 deletions src/ansibleguy-webui/aw/model/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ def log_stdout_repo_url(self) -> str:
def log_stderr_repo_url(self) -> str:
return f"/api/job/{self.job.id}/{self.id}/log?type=stderr_repo"

@property
def user_name(self) -> str:
return self.user.username if self.user is not None else 'Scheduled'


class JobQueue(BareModel):
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='jobqueue_fk_job')
Expand Down
Loading

0 comments on commit 4f5dd27

Please sign in to comment.