Skip to content

Commit

Permalink
alert email templates
Browse files Browse the repository at this point in the history
  • Loading branch information
ansibleguy committed May 12, 2024
1 parent 34e64cc commit c437db1
Show file tree
Hide file tree
Showing 13 changed files with 314 additions and 89 deletions.
Binary file added docs/source/_static/img/alert_email.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 50 additions & 5 deletions docs/source/usage/alerts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

.. include:: ../_include/warn_develop.rst

.. |alert_email| image:: ../_static/img/alert_email.png
:class: wiki-img

======
Alerts
Expand Down Expand Up @@ -37,6 +39,10 @@ You need to configure your mailserver at the :code:`System - Config` page.

After that you can receive e-mails on job finish/failure.

**Example Mail**:

|alert_email|

----

Plugins
Expand Down Expand Up @@ -64,8 +70,8 @@ There is a generic alert-plugin interface for custom solutions.
"first_name": "",
"last_name": "",
"email": "ansible@localhost",
"phone": "",
"description": "",
"phone": null,
"description": null,
"is_active": true,
"last_login": 1715445321,
"groups": []
Expand All @@ -79,8 +85,8 @@ There is a generic alert-plugin interface for custom solutions.
"time_start_pretty": "2024-05-11 18:04:10 CEST",
"time_fin": 1715450651,
"time_fin_pretty": "2024-05-11 18:04:11 CEST",
"time_duration" 2,
"time_duration_pretty" "2s",
"time_duration": 1.036579,
"time_duration_pretty": "2s",
"error_short": null,
"error_med": null,
"log_stdout": "/home/guy/.local/share/ansible-webui/test2_2024-05-11_18-04-10_ansible_stdout.log",
Expand All @@ -92,11 +98,50 @@ There is a generic alert-plugin interface for custom solutions.
"log_stderr_repo": null,
"log_stderr_repo_url": null
},
"stats": {}
"stats": {
"localhost": {
"unreachable": false,
"tasks_skipped": 3,
"tasks_ok": 1,
"tasks_failed": 0,
"tasks_rescued": 0,
"tasks_ignored": 0,
"tasks_changed": 0
}
}
}
* Create a plugin at :code:`Settings - Alerts` that points to your executable

* Link the plugin in alerts

* Test it

----

Example Plugin
--------------

.. code-block:: python3
#!/usr/bin/env python3
from sys import argv
from sys import exit as sys_exit
from json import loads as json_loads
with open(argv[1], 'r', encoding='utf-8') as _f:
data = json_loads(_f.read())
# implement alerting
if data['execution']['failed']:
# failure action
for host, stats in data['stats'].items():
if stats['unreachable'] or stats['tasks_failed'] > 0:
# hosts that failed
pass
sys_exit(0)
17 changes: 16 additions & 1 deletion docs/source/usage/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ CSRF Failed

----

SSH Shared connection
=====================

**Error**: While executing Ansible you see: :code:`Shared connection to <IP> closed`

**Problem**:

* This seems to be an issue of how Ansible calls SSH. Have seen it happen on a few systems - even with using vanilla Ansible via CLI.

The issue is that a :code:`mux` process has not terminated gracefully.

Search for the process: :code:`ps -aux | grep mux` and kill it `kill -9 <PID>` (*the PID is the number in the second column*)

----

.. _usage_troubleshooting_saml:

SAML Issues
Expand Down Expand Up @@ -241,7 +256,7 @@ Too Many Log Files exist
MAX_LOG_AGE=7 # days
cd ~/.local/share/ansible-webui/
find -type f -mtime +${MAX_LOG_AGE} -delete
find -type f -mtime +${MAX_LOG_AGE} -name "*.log" -delete
----

Expand Down
22 changes: 21 additions & 1 deletion src/ansibleguy-webui/aw/api_endpoints/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from aw.utils.permission import has_job_permission, has_credentials_permission, has_manager_privileges
from aw.execute.queue import queue_add
from aw.execute.util import update_status, is_execution_status
from aw.utils.util import is_set
from aw.utils.util import is_set, ansible_log_html, ansible_log_text
from aw.base import USERS


Expand Down Expand Up @@ -355,11 +355,25 @@ class APIJobExecutionLogs(APIView):
403: OpenApiResponse(JobExecutionLogReadResponse, description='Not privileged to view the job logs'),
404: OpenApiResponse(JobExecutionLogReadResponse, description='Job, execution or log-file do not exist'),
},
parameters=[
OpenApiParameter(
name='format', type=str, default='html',
description="Format to return - one of 'plain', 'text' or 'html'",
required=False,
),
],
summary='Get logs of a job execution.',
operation_id='job_exec_logs'
)
def get(self, request, job_id: int, exec_id: int, line_start: int = 0):
user = get_api_user(request)

if 'format' not in request.GET:
log_fmt = 'html'

else:
log_fmt = str(request.GET['format'])

try:
job, execution = _find_job_and_execution(job_id, exec_id)

Expand All @@ -372,6 +386,12 @@ def get(self, request, job_id: int, exec_id: int, line_start: int = 0):

with open(execution.log_stdout, 'r', encoding='utf-8') as logfile:
lines = logfile.readlines()
if log_fmt == 'html':
lines = [ansible_log_html(line) for line in lines]

elif log_fmt == 'text':
lines = [ansible_log_text(line) for line in lines]

return Response(data={'lines': lines[line_start:]}, status=200)

except (ObjectDoesNotExist, FileNotFoundError):
Expand Down
28 changes: 25 additions & 3 deletions src/ansibleguy-webui/aw/execute/alert.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from pathlib import Path

from django.db.models import Q

from aw.base import USERS
from aw.utils.util import ansible_log_text, ansible_log_html
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
Expand All @@ -22,12 +25,25 @@ def __init__(self, job: Job, execution: JobExecution):
self.privileged_users.append(user)

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

self.error_msgs = {'html': [], 'text': []}
self._get_task_errors()

def _get_task_errors(self):
if self.failed and Path(self.execution.log_stdout).is_file():
with open(self.execution.log_stdout, 'r', encoding='utf-8') as _f:
for line in _f.readlines():
line_text = ansible_log_text(line)
line_html = ansible_log_html(line)
if line_text.startswith('fatal: '):
self.error_msgs['html'].append(line_html)
self.error_msgs['text'].append(line_text)

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

Expand All @@ -49,10 +65,16 @@ def _route(self, alert: BaseAlert, user: USERS):
stats=self.stats,
execution=self.execution,
failed=self.failed,
error_msgs=self.error_msgs,
)

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

def _global(self):
for alert in self._condition_filter(self._job_filter(AlertGlobal)):
Expand Down
55 changes: 14 additions & 41 deletions src/ansibleguy-webui/aw/execute/alert_plugin/plugin_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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 django.template.loader import get_template

from aw.base import USERS
from aw.utils.util import valid_email
Expand All @@ -14,49 +15,19 @@
from aw.model.system import MAIL_TRANSPORT_TYPE_SSL, MAIL_TRANSPORT_TYPE_STARTTLS


def _email_send(server: SMTP, user: USERS, stats: dict, execution: JobExecution):
def _email_send(server: SMTP, user: USERS, stats: dict, execution: JobExecution, error_msgs: dict):
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['Subject'] = f"Ansible WebUI - 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}'
"""
tmpl_ctx = {'execution': execution, 'stats': stats, 'web_addr': get_main_web_address(), 'error_msgs': error_msgs}
text_content = get_template('email/alert.txt').render(tmpl_ctx)
html_content = get_template('email/alert.html').render(tmpl_ctx)

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'))
msg.attach(MIMEText(text_content, 'plain'))
msg.attach(MIMEText(html_content, 'html'))

server.sendmail(
from_addr=config['mail_sender'],
Expand All @@ -65,7 +36,7 @@ def _email_send(server: SMTP, user: USERS, stats: dict, execution: JobExecution)
)


def alert_plugin_email(user: USERS, stats: dict, execution: JobExecution):
def alert_plugin_email(user: USERS, stats: dict, execution: JobExecution, error_msgs: dict):
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
Expand All @@ -90,14 +61,16 @@ def alert_plugin_email(user: USERS, stats: dict, execution: JobExecution):
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)
_email_send(server=server, user=user, stats=stats, execution=execution, error_msgs=error_msgs)

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)
_email_send(server=server, user=user, stats=stats, execution=execution, error_msgs=error_msgs)

log(msg=f"Sent alert email to: {user.username} ({user.email})", level=6)

except (SMTPResponseException, OSError) as e:
log(msg=f"Got error sending alert mail: {e}", level=2)
10 changes: 8 additions & 2 deletions src/ansibleguy-webui/aw/execute/alert_plugin/plugin_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
from aw.model.alert import BaseAlert, AlertUser, AlertGroup


def alert_plugin_wrapper(alert: BaseAlert, user: USERS, stats: dict, execution: JobExecution, failed: bool):
def alert_plugin_wrapper(
alert: BaseAlert, user: USERS, stats: dict, execution: JobExecution, failed: bool,
error_msgs: dict,
):
if not Path(alert.plugin.executable).is_file():
log(
msg=f"Alert plugin has an invalid executable configured: {alert.name} ({alert.plugin.executable})",
Expand Down Expand Up @@ -55,6 +58,7 @@ def alert_plugin_wrapper(alert: BaseAlert, user: USERS, stats: dict, execution:
'error_short': None,
'error_med': None,
},
'errors': error_msgs,
'stats': stats,
}

Expand All @@ -75,7 +79,7 @@ def alert_plugin_wrapper(alert: BaseAlert, user: USERS, stats: dict, execution:
if execution.result is not None:
data['execution']['time_fin'] = int(unix_timestamp(execution.result.time_fin_dt.timetuple()))
data['execution']['time_fin_pretty'] = execution.result.time_fin_str
data['execution']['time_duration'] = execution.result.time_duration
data['execution']['time_duration'] = execution.result.time_duration.total_seconds()
data['execution']['time_duration_pretty'] = execution.result.time_duration_str

if execution.result.error is not None:
Expand Down Expand Up @@ -106,4 +110,6 @@ def alert_plugin_wrapper(alert: BaseAlert, user: USERS, stats: dict, execution:
if result['rc'] != 0:
log(f"Alert plugin failed! Output: '{result['stdout']}' | Error: '{result['stderr']}'")

log(msg=f"Executed alert plugin '{alert.plugin.name}' targeting user: {user.username}", level=6)

remove_file(tmp_file)
4 changes: 2 additions & 2 deletions src/ansibleguy-webui/aw/model/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ def get_schema_metadata() -> SchemaMetadata:

class UserExtended(models.Model):
user = models.OneToOneField(USERS, on_delete=models.CASCADE)
phone = models.CharField(max_length=100)
description = models.CharField(max_length=1000)
phone = models.CharField(max_length=100, **DEFAULT_NONE)
description = models.CharField(max_length=1000, **DEFAULT_NONE)

class Meta:
constraints = [
Expand Down
Loading

0 comments on commit c437db1

Please sign in to comment.