From 350305a09f2af975df58ff3a792f2ec5d3fd10ac Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Wed, 29 May 2024 12:15:20 +0200 Subject: [PATCH] Email Notification Plugin Created Added sphinx target inside configuring.rst Created symbolic link inside of documentation Plugin name finalised Altered towards comments Signed-off-by: Harvey Lynden --- .../source/plugins/optional/results/index.rst | 1 + docs/source/plugins/optional/results/mail.rst | 1 + .../job-pre-post/mail/avocado_job_mail.py | 75 ----- examples/plugins/job-pre-post/mail/setup.py | 19 -- optional_plugins/mail/MANIFEST.in | 1 + optional_plugins/mail/README | 1 + optional_plugins/mail/README.rst | 160 ++++++++++ optional_plugins/mail/VERSION | 1 + .../mail/avocado_result_mail/__init__.py | 0 .../mail/avocado_result_mail/result_mail.py | 298 ++++++++++++++++++ optional_plugins/mail/setup.py | 51 +++ python-avocado.spec | 21 ++ spell.ignore | 4 + 13 files changed, 539 insertions(+), 94 deletions(-) create mode 120000 docs/source/plugins/optional/results/mail.rst delete mode 100644 examples/plugins/job-pre-post/mail/avocado_job_mail.py delete mode 100644 examples/plugins/job-pre-post/mail/setup.py create mode 100644 optional_plugins/mail/MANIFEST.in create mode 120000 optional_plugins/mail/README create mode 100755 optional_plugins/mail/README.rst create mode 100644 optional_plugins/mail/VERSION create mode 100644 optional_plugins/mail/avocado_result_mail/__init__.py create mode 100644 optional_plugins/mail/avocado_result_mail/result_mail.py create mode 100644 optional_plugins/mail/setup.py diff --git a/docs/source/plugins/optional/results/index.rst b/docs/source/plugins/optional/results/index.rst index 47cb36d9e3..fb75bafa3e 100644 --- a/docs/source/plugins/optional/results/index.rst +++ b/docs/source/plugins/optional/results/index.rst @@ -18,3 +18,4 @@ where these examples are included. :: html result_upload resultsdb + mail \ No newline at end of file diff --git a/docs/source/plugins/optional/results/mail.rst b/docs/source/plugins/optional/results/mail.rst new file mode 120000 index 0000000000..a4f5835af4 --- /dev/null +++ b/docs/source/plugins/optional/results/mail.rst @@ -0,0 +1 @@ +../../../../../optional_plugins/mail/README.rst \ No newline at end of file diff --git a/examples/plugins/job-pre-post/mail/avocado_job_mail.py b/examples/plugins/job-pre-post/mail/avocado_job_mail.py deleted file mode 100644 index 9bdb9ca3a9..0000000000 --- a/examples/plugins/job-pre-post/mail/avocado_job_mail.py +++ /dev/null @@ -1,75 +0,0 @@ -import smtplib -from email.mime.text import MIMEText - -from avocado.core.output import LOG_UI -from avocado.core.plugin_interfaces import Init, JobPost, JobPre -from avocado.core.settings import settings - - -class MailInit(Init): - name = "mail-init" - description = "Mail plugin initialization" - - def initialize(self): - help_msg = "Mail recipient." - settings.register_option( - section="plugins.job.mail", - key="recipient", - default="root@localhost.localdomain", - help_msg=help_msg, - ) - - help_msg = "Mail header." - settings.register_option( - section="plugins.job.mail", - key="header", - default="[AVOCADO JOB NOTIFICATION]", - help_msg=help_msg, - ) - - help_msg = "Mail sender." - settings.register_option( - section="plugins.job.mail", - key="sender", - default="avocado@localhost.localdomain", - help_msg=help_msg, - ) - - help_msg = "Mail server." - settings.register_option( - section="plugins.job.mail", - key="server", - default="localhost", - help_msg=help_msg, - ) - - -class Mail(JobPre, JobPost): - name = "mail" - description = "Sends mail to notify on job start/end" - - @staticmethod - def mail(job): - rcpt = job.config.get("plugins.job.mail.recipient") - header = job.config.get("plugins.job.mail.header") - sender = job.config.get("plugins.job.mail.sender") - server = job.config.get("plugins.job.mail.server") - # build proper subject based on job status - subject = f"{header} Job {job.unique_id} - Status: {job.status}" - msg = MIMEText(subject) - msg["Subject"] = subject - msg["From"] = sender - msg["To"] = rcpt - - # So many possible failures, let's just tell the user about it - try: - smtp = smtplib.SMTP(server) - smtp.sendmail(sender, [rcpt], msg.as_string()) - smtp.quit() - except Exception: # pylint: disable=W0703 - LOG_UI.error( - "Failure to send email notification: " - "please check your mail configuration" - ) - - pre = post = mail diff --git a/examples/plugins/job-pre-post/mail/setup.py b/examples/plugins/job-pre-post/mail/setup.py deleted file mode 100644 index 2c7de8d364..0000000000 --- a/examples/plugins/job-pre-post/mail/setup.py +++ /dev/null @@ -1,19 +0,0 @@ -from setuptools import setup - -name = "avocado_job_mail" -init_klass = "MailInit" -klass = "Mail" -entry_point = f"{name} = {name}:{klass}" -init_entry_point = f"{name} = {name}:{init_klass}" - -if __name__ == "__main__": - setup( - name=name, - version="1.0", - description="Avocado Pre/Post Job Mail Notification", - py_modules=[name], - entry_points={ - "avocado.plugins.init": [init_entry_point], - "avocado.plugins.job.prepost": [entry_point], - }, - ) diff --git a/optional_plugins/mail/MANIFEST.in b/optional_plugins/mail/MANIFEST.in new file mode 100644 index 0000000000..88c0d60cef --- /dev/null +++ b/optional_plugins/mail/MANIFEST.in @@ -0,0 +1 @@ +include VERSION README.rst diff --git a/optional_plugins/mail/README b/optional_plugins/mail/README new file mode 120000 index 0000000000..92cacd2853 --- /dev/null +++ b/optional_plugins/mail/README @@ -0,0 +1 @@ +README.rst \ No newline at end of file diff --git a/optional_plugins/mail/README.rst b/optional_plugins/mail/README.rst new file mode 100755 index 0000000000..b8d9b7434d --- /dev/null +++ b/optional_plugins/mail/README.rst @@ -0,0 +1,160 @@ +Mail results Plugin +=================== + +The Mail result plugin enables you to receive email notifications +for job start and completion events within the Avocado testing framework. + +.. note:: Currently only supports Gmail. + +Installation +------------ + +To install the Mail results plugin from pip, use: + +.. code-block:: bash + + $ pip install avocado-framework-plugin-result-mail + +Configuration +------------- + +To use the Mail Result plugin, you need to configure it +in the Avocado settings file. + +(`avocado.conf` located /etc/avocado/ if not present you can create the file) +Below is an configuration example: + +.. note:: More information on configuration here. + For detailed configuration instructions, + please visit `Avocado Configuration `_. + + +.. code-block:: ini + + [plugins.mail] + + # The email address to which job notification emails will be sent. + recipient = avocado@local.com + + # The subject header for the job notification emails. + header = [AVOCADO JOB NOTIFICATION] + + # The email address from which the job notification emails will be sent. + sender = avocado@local.com + + # The SMTP server address for sending the emails. + server = smtp.gmail.com + + # The SMTP server port for sending the emails. + port = 587 + + # The application-specific password for the sender email address. + password = abcd efgh ijkl mnop + + # The detail level of the email content. + # Set to false for a summary with essential details + # or true for detailed information about each failed test. + verbose = false + +Usage +----- + +Once configured, the Mail result plugin will automatically +send email notifications for job start and completion events +based on the specified settings. + +Obtaining an App Password for Gmail +----------------------------------- + +Please follow these steps to generate an App Password: + +Create & use app passwords + +Important: To create an app password, +you need 2-Step Verification on your Google Account. + +#. Go to your Google Account. +#. Select Security. +#. Under "How you sign in to Google," select 2-Step Verification. +#. At the bottom of the page, select App passwords. +#. Enter a name that helps you remember where you’ll use the app password. +#. Select Generate. +#. To enter the app password, follow the instructions on your screen. +#. The app password is the 16-character code that generates on your device. +#. Select Done. + +Enter the App Password inside of the avocado configuration file. + +Remember to keep this App Password secure and don't share it with anyone. +If you suspect it has been compromised, +you can always revoke it and generate a new one. + +Example Plugin Outputs +---------------------- + +.. code-block:: none + + Verbose True + + Job Notification - Job abca44fb69558024b0af74a5654ab282f00f1253 + =============================================================== + + Job Total Time: 24.03 Seconds + Job Finished At: 2024-06-25 16:31:58 + + Results + ------- + + - PASS: 6 + - ERROR: 0 + - FAIL: 1 + - SKIP: 0 + - WARN: 0 + - INTERRUPT: 0 + - CANCEL: 0 + + Test Summary + ------------ + + Name: selftests/safeloader.sh + Status: FAIL + Fail Reason: + Actual Time Start: 1719325898.06546 + Actual Time End: 1719325902.474006 + ID: static-checks-4-selftests/safeloader.sh + Log Directory: /home/hlynden/avocado/job-results/job-2024-06-25T16.31-abca44f/test-results/static-checks-4-selftests_safeloader.sh + Log File: /home/hlynden/avocado/job-results/job-2024-06-25T16.31-abca44f/test-results/static-checks-4-selftests_safeloader.sh/debug.log + Time Elapsed: 4.410571283999161 seconds + Time Start: 22630.607959844 + Time End: 22635.018531128 + Tags: {} + Whiteboard: + + + +.. code-block:: none + + Verbose False + + Job Notification - Job 83da84014a9cbe7a89bea398eb1608dc04743897 + =============================================================== + + Job Total Time: 24.03 Seconds + Job Finished At: 2024-06-25 16:31:58 + + Results + ------- + + - PASS: 6 + - ERROR: 0 + - FAIL: 1 + - SKIP: 0 + - WARN: 0 + - INTERRUPT: 0 + - CANCEL: 0 + + Test Summary + ------------ + + Name: selftests/safeloader.sh + Fail Reason: diff --git a/optional_plugins/mail/VERSION b/optional_plugins/mail/VERSION new file mode 100644 index 0000000000..2f3d1b7405 --- /dev/null +++ b/optional_plugins/mail/VERSION @@ -0,0 +1 @@ +106.0 diff --git a/optional_plugins/mail/avocado_result_mail/__init__.py b/optional_plugins/mail/avocado_result_mail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/optional_plugins/mail/avocado_result_mail/result_mail.py b/optional_plugins/mail/avocado_result_mail/result_mail.py new file mode 100644 index 0000000000..b8f22ce8b2 --- /dev/null +++ b/optional_plugins/mail/avocado_result_mail/result_mail.py @@ -0,0 +1,298 @@ +import json +import logging +import os +import smtplib +import time +from email.mime.text import MIMEText + +from avocado.core.output import LOG_UI +from avocado.core.plugin_interfaces import Init, JobPost, JobPre +from avocado.core.settings import settings + +# Create a logger for avocado.job.mail +job_log = logging.getLogger("avocado.job.mail") + + +class MailInit(Init): + name = "mail-init" + description = "Mail plugin initialization" + + def initialize(self): + help_msg = "Mail recipient." + settings.register_option( + section="plugins.mail", + key="recipient", + default="root@localhost.localdomain", + help_msg=help_msg, + ) + + help_msg = "Mail header." + settings.register_option( + section="plugins.mail", + key="header", + default="[AVOCADO JOB NOTIFICATION]", + help_msg=help_msg, + ) + + help_msg = "Mail sender." + settings.register_option( + section="plugins.mail", + key="sender", + default="avocado@localhost.localdomain", + help_msg=help_msg, + ) + + help_msg = "Mail server." + settings.register_option( + section="plugins.mail", + key="server", + default="localhost", + help_msg=help_msg, + ) + + help_msg = "Mail server port." + settings.register_option( + section="plugins.mail", + key="port", + default=587, + help_msg=help_msg, + ) + + help_msg = "Mail server Application Password." + settings.register_option( + section="plugins.mail", + key="password", + default="", + help_msg=help_msg, + ) + + help_msg = "Email detail level." + settings.register_option( + section="plugins.mail", + key="verbose", + default=False, + help_msg=help_msg, + key_type=bool, + ) + + +class Mail(JobPre, JobPost): + name = "mail" + description = "Sends mail to notify on job start/end" + + def __init__(self): + super().__init__() + self.enabled = True + self.start_email_sent = False + + def initialize(self, job): + if not self._validate_email_config(job): + self.enabled = False + super().initialize(job) + + @staticmethod + def _get_smtp_config(job): + return ( + job.config.get("plugins.mail.server"), + job.config.get("plugins.mail.port"), + job.config.get("plugins.mail.sender"), + job.config.get("plugins.mail.password", ""), + ) + + @staticmethod + def _build_message(job, time_content, phase, finishedtime="", test_summary=""): + if phase == "Post": + subject_prefix = "Job Completed" + else: + subject_prefix = "Job Started" + + body = f""" + + +

Job Notification - Job {job.unique_id}

+ """ + + if phase == "Post": + body += f""" +

Job Total Time: {time_content}

+ """ + + body += f""" +

Job Finished At: {finishedtime}

+

Results:

+
    +
  • PASS: {job.result.passed}
  • +
  • ERROR: {job.result.errors}
  • +
  • FAIL: {job.result.failed}
  • +
  • SKIP: {job.result.skipped}
  • +
  • WARN: {job.result.warned}
  • +
  • INTERRUPT: {job.result.interrupted}
  • +
  • CANCEL: {job.result.cancelled}
  • +
+

Test Summary:

+
{test_summary}
+ """ + elif phase == "Start": + body += f""" +

Job Started At: {time_content}

+ """ + + body += """ + + + """ + + msg = MIMEText(body, "html") + msg["Subject"] = ( + f"{job.config.get('plugins.mail.header')} Job {job.unique_id} - Status: {subject_prefix}" + ) + msg["From"] = job.config.get("plugins.mail.sender") + msg["To"] = job.config.get("plugins.mail.recipient") + return msg + + @staticmethod + def _send_email(smtp, sender, rcpt, msg): + try: + smtp.sendmail(sender, [rcpt], msg.as_string()) + LOG_UI.info("EMAIL SENT TO: %s", rcpt) + except Exception as e: + job_log.error(f"Failure to send email notification to {rcpt}: {e}") + + @staticmethod + def _smtp_login_and_send(job, msg): + server, port, sender, password = Mail._get_smtp_config(job) + smtp = Mail._create_smtp_connection(server, port) + if smtp: + try: + smtp.login(sender, password) + except Exception as e: + job_log.error(f"SMTP login failed: {e}") + return False + + Mail._send_email( + smtp, sender, job.config.get("plugins.mail.recipient"), msg + ) + smtp.quit() + return True + else: + job_log.error( + "Failed to establish SMTP connection. Skipping email notification." + ) + return False + + @staticmethod + def _create_smtp_connection(server, port): + try: + smtp = smtplib.SMTP(server, port) + smtp.starttls() # Enable TLS + return smtp + except Exception as e: # pylint: disable=W0703 + job_log.error( + f"Failed to establish SMTP connection to {server}:{port}: {e}" + ) + return None + + @staticmethod + def _read_results_file(results_path): + try: + with open(results_path, "r", encoding="utf-8") as file: + return json.load(file) + except FileNotFoundError: + job_log.error("Test summary file not found at %s.", results_path) + return None + except json.JSONDecodeError: + job_log.error("Error decoding JSON from file %s.", results_path) + return None + except Exception as e: + job_log.error("Unexpected error while reading test summary: %s", str(e)) + return None + + @staticmethod + def _format_test_details(test, advanced=False): + if advanced: + details = [ + f"Name: {test.get('name', '')}
", + f"Status: {test.get('status', '')}
", + f"Fail Reason: {test.get('fail_reason', '')}
", + f"Actual Time Start: {test.get('actual_time_start', '')}
", + f"Actual Time End: {test.get('actual_time_end', '')}
", + f"ID: {test.get('id', '')}
", + f"Log Directory: {test.get('logdir', '')}
", + f"Log File: {test.get('logfile', '')}
", + f"Time Elapsed: {test.get('time_elapsed', '')}
", + f"Time Start: {test.get('time_start', '')}
", + f"Time End: {test.get('time_end', '')}
", + f"Tags: {test.get('tags', '')}
", + f"Whiteboard: {test.get('whiteboard', '')}
", + ] + else: + details = [ + f"Name: {test.get('name', '')}
", + f"Fail Reason: {test.get('fail_reason', '')}
", + ] + return "".join(details) + + @staticmethod + def _generate_test_summary(data, verbose): + test_summary = [] + + def format_test_details(test): + return Mail._format_test_details(test, advanced=verbose) + + for test in data.get("tests", []): + if test.get("status") == "FAIL": + test_summary.append(format_test_details(test)) + + return "\n\n".join(test_summary) + + @staticmethod + def _get_test_summary(job): + results_path = os.path.join(job.logdir, "results.json") + data = Mail._read_results_file(results_path) + if not data: + return "" + + verbose = job.config.get("plugins.mail.verbose") + return Mail._generate_test_summary(data, verbose) + + def pre(self, job): + if not self.enabled: + return + + phase = "Start" + start_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + time_content = f"{start_time}" + + msg = self._build_message(job, time_content, phase) + self.start_email_sent = self._smtp_login_and_send(job, msg) + + def post(self, job): + if not self.enabled or not self.start_email_sent: + return + + phase = "Post" + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + finishedtime = f"{current_time}" + + time_elapsed_formatted = f"{job.time_elapsed:.2f}" + time_content = f"{time_elapsed_formatted} Seconds" + + test_summary = self._get_test_summary(job) + + msg = self._build_message(job, time_content, phase, finishedtime, test_summary) + self._smtp_login_and_send(job, msg) + + def _validate_email_config(self, job): + required_keys = ["recipient", "sender", "server", "port"] + for key in required_keys: + if not job.config.get(f"plugins.mail.{key}"): + job_log.error( + f"Email configuration {key} is missing. Disabling Plugin." + ) + return False + + if job.config.get("plugins.mail.sender") == "avocado@localhost.localdomain": + job_log.error("Email sender is still set to default. Disabling Plugin.") + return False + + return True diff --git a/optional_plugins/mail/setup.py b/optional_plugins/mail/setup.py new file mode 100644 index 0000000000..d4c614f426 --- /dev/null +++ b/optional_plugins/mail/setup.py @@ -0,0 +1,51 @@ +#!/bin/env python3 +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# +# See LICENSE for more details. +# +# Copyright: Red Hat Inc. 2024 +# Author: Harvey Lynden + +import os + +from setuptools import find_packages, setup + +BASE_PATH = os.path.dirname(__file__) +with open(os.path.join(BASE_PATH, "VERSION"), "r", encoding="utf-8") as version_file: + VERSION = version_file.read().strip() + + +def get_long_description(): + with open(os.path.join(BASE_PATH, "README.rst"), "rt", encoding="utf-8") as readme: + readme_contents = readme.read() + return readme_contents + + +setup( + name="avocado-framework-plugin-result-mail", + version=VERSION, + description="Avocado Mail Notification for Jobs", + long_description=get_long_description(), + long_description_content_type="text/x-rst", + author="Avocado Developers", + author_email="avocado-devel@redhat.com", + url="http://avocado-framework.github.io/", + packages=find_packages(), + include_package_data=True, + install_requires=[f"avocado-framework=={VERSION}"], + entry_points={ + "avocado.plugins.init": [ + "notification = avocado_result_mail.result_mail:MailInit", + ], + "avocado.plugins.job.prepost": [ + "notification = avocado_result_mail.result_mail:Mail", + ], + }, +) diff --git a/python-avocado.spec b/python-avocado.spec index 9755cb6d2f..5f71e4f205 100644 --- a/python-avocado.spec +++ b/python-avocado.spec @@ -135,6 +135,9 @@ popd pushd optional_plugins/result_upload %py3_build popd +pushd optional_plugins/mail +%py3_build +popd %if ! 0%{?rhel} pushd optional_plugins/spawner_remote %py3_build @@ -175,6 +178,9 @@ popd pushd optional_plugins/result_upload %py3_install popd +pushd optional_plugins/mail +%py3_install +popd %if ! 0%{?rhel} pushd optional_plugins/spawner_remote %py3_install @@ -239,6 +245,7 @@ PATH=%{buildroot}%{_bindir}:%{buildroot}%{_libexecdir}/avocado:$PATH \ %exclude %{python3_sitelib}/avocado_varianter_pict* %exclude %{python3_sitelib}/avocado_varianter_cit* %exclude %{python3_sitelib}/avocado_result_upload* +%exclude %{python3_sitelib}/avocado_result_mail* %exclude %{python3_sitelib}/avocado_framework_plugin_result_html* %exclude %{python3_sitelib}/avocado_framework_plugin_resultsdb* %exclude %{python3_sitelib}/avocado_framework_plugin_varianter_yaml_to_mux* @@ -247,6 +254,7 @@ PATH=%{buildroot}%{_bindir}:%{buildroot}%{_libexecdir}/avocado:$PATH \ %exclude %{python3_sitelib}/avocado_framework_plugin_golang* %exclude %{python3_sitelib}/avocado_framework_plugin_ansible* %exclude %{python3_sitelib}/avocado_framework_plugin_result_upload* +%exclude %{python3_sitelib}/avocado_framework_plugin_result_mail* %exclude %{python3_sitelib}/avocado_framework_plugin_spawner_remote* %exclude %{python3_sitelib}/tests* @@ -396,6 +404,19 @@ a dedicated sever. %{python3_sitelib}/avocado_result_upload* %{python3_sitelib}/avocado_framework_plugin_result_upload* +%package -n python3-avocado-plugins-result-mail +Summary: Avocado Mail Notification for Jobs +License: GPLv2+ +Requires: python3-avocado == %{version}-%{release} + +%description -n python3-avocado-plugins-result-mail +The Mail result plugin enables you to receive email notifications +for job start and completion events within the Avocado testing framework. + +%files -n python3-avocado-plugins-result-mail +%{python3_sitelib}/avocado_result_mail* +%{python3_sitelib}/avocado_framework_plugin_result_mail* + %if ! 0%{?rhel} %package -n python3-avocado-plugins-spawner-remote Summary: Avocado Plugin to spawn tests on a remote host diff --git a/spell.ignore b/spell.ignore index e3d644f1f4..b498bc5e7c 100644 --- a/spell.ignore +++ b/spell.ignore @@ -778,3 +778,7 @@ matplotlib enum bitwise secureboot +hlynden +Harvey +Lynden +TLS