Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions actions/email.users.for.power.testing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
description: Sends an email to inform users about VMs running on flavors scheduled for power testing or decommissioning
enabled: true
entry_point: src/openstack_actions.py
name: email.users.with.power.test.flavors
parameters:
timeout:
default: 5400
lib_entry_point:
default: workflows.send_power_testing_email.send_power_testing_email
immutable: true
type: string
subject:
type: string
description: "Subject to add to each email"
required: true
default: "STFC Cloud Flavor Power Testing Notice"
smtp_account_name:
type: string
description: "Name of SMTP Account to use. Must be configured in the pack settings."
required: true
default: "default"
cloud_account:
description: "The clouds.yaml account to use whilst performing this action"
required: true
type: string
default: "dev"
enum:
- "dev"
- "prod"
flavor_name_list:
type: array
description: "List of flavor names to include in the power testing notice"
required: true
default: null
limit_by_projects:
type: array
description: "List of project names or IDs to limit action to - incompatible with all_projects"
required: false
default: null
all_projects:
type: boolean
description: "Search across all projects - mutually exclusive with limit_by_projects"
required: true
default: true
use_override:
type: boolean
description: "Redirect all emails to override address"
required: true
default: false
as_html:
type: boolean
description: "Send email body as HTML"
required: true
default: true
send_email:
type: boolean
description: "Actually send emails instead of just printing what would be sent"
required: true
default: false
override_email_address:
type: string
description: "Override recipient email address"
required: true
default: "cloud-support@stfc.ac.uk"
cc_cloud_support:
type: boolean
description: "CC cloud-support@stfc.ac.uk on all emails"
required: false
default: false
email_from:
type: string
description: "Email address to send email from"
required: true
default: "cloud-support@stfc.ac.uk"
immutable: true
runner_type: python-script
8 changes: 7 additions & 1 deletion lib/apis/email_api/email_template_schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,10 @@ decom_capi_image:
html_filepath: "html/decom_capi_image.html.j2"
plaintext_filepath: "plaintext/decom_capi_image.txt.j2"


power_testing:
schema:
user_name: null
affected_flavors_table: "No Flavors"
affected_servers_table: "No VMs affected"
html_filepath: "html/power_testing.html.j2"
plaintext_filepath: "plaintext/power_testing.txt.j2"
64 changes: 55 additions & 9 deletions lib/apis/email_api/emailer.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import time
import ssl
from typing import List
from pathlib import Path
from smtplib import SMTP
import logging

import ssl
import time
from email.header import Header
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate

from apis.email_api.template_handler import TemplateHandler
from pathlib import Path
from smtplib import SMTP
from typing import List

from apis.email_api.structs.email_params import EmailParams
from apis.email_api.structs.smtp_account import SMTPAccount
from apis.email_api.structs.email_template_details import EmailTemplateDetails
from apis.email_api.structs.smtp_account import SMTPAccount
from apis.email_api.template_handler import TemplateHandler

# weird issue where pylint can't find the module - works fine though
# pylint:disable=no-name-in-module
Expand Down Expand Up @@ -90,6 +88,54 @@ def send_emails(self, emails: List[EmailParams]):

logger.info("sending complete - time elapsed: %s seconds", time.time() - start)

def print_email(self, email_params: EmailParams):
"""
Print a well-formatted version of the email that would be sent.
Useful for debugging or dry-run previews.
"""
# Build the MIME email using the same function that send_email uses
msg = self.build_email(email_params)

# Display email headers
print("===== EMAIL PREVIEW =====")
print(f"From : {msg['From']}")
print(f"To : {msg['To']}")
print(f"Cc : {msg['Cc'] or '<none>'}")
print(f"Subject: {msg['Subject']}")
print(f"Date : {msg['Date']}")
print(f"Reply-To: {msg['reply-to']}")
print("\n--- Body ---")

# Extract and print the body parts (could be plain or HTML)
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = part.get("Content-Disposition", "").lower()
if (
content_type.startswith("text/")
and "attachment" not in content_disposition
):
charset = part.get_content_charset() or "utf-8"
body = part.get_payload(decode=True).decode(charset, errors="replace")
print(body)
print("\n--- End of Body ---\n")

# List any attachments
attachments = []
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "").lower()
if "attachment" in content_disposition:
filename = part.get_filename()
attachments.append(filename)

if attachments:
print("--- Attachments ---")
for filename in attachments:
print(f"- {filename}")
else:
print("No attachments.")

print("=========================\n")

def build_email(self, email_params: EmailParams) -> MIMEMultipart:
"""
Helper function to setup email as MIMEMultipart
Expand Down
28 changes: 28 additions & 0 deletions lib/apis/email_api/templates/html/power_testing.html.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<p>Dear {{ user_name }},</p>

<p>This is a reminder of the upcoming planned <strong>electrical maintenance works</strong>.</p>

<p><strong>These works are planned for:</strong><br>
<ul>
<li>1st-2nd November</li>
<li>8th-9th November</li>
</ul></p>

<p><strong>This will impact the following flavors</strong> — VMs using these flavors will be powered down and <strong><u>NOT ACCESSIBLE</u></strong> during the periods mentioned above:<br>
{{ affected_flavors_table }}</p>

<p><strong>We would like to request that the following VMs be shut down ahead of the first weekend</strong>, shutting down by <strong>17:00 on Thursday 30th October 2025</strong>.<br>
{{ affected_servers_table }}</p>

<p><strong>Any impacted VMs still running will be shut down</strong> during the morning of <strong>Friday 31st October</strong> by the Cloud Operations Team.</p>

<p><strong>VM access will then be restored</strong> during the afternoon of <strong>Monday 3rd November</strong>, assuming all works are completed.</p>

<p>While the power work is expected to be completed by <strong>Monday 3rd November</strong>, we plan to operate <strong><u>AT RISK</u></strong> for the period beginning <strong>Friday 31st October</strong> through to <strong>Wednesday 12th November</strong>.</p>

<p>Any questions or concerns regarding these works can be raised via our support portal, via tickets to
<a href="mailto:cloud-support@stfc.ac.uk">cloud-support@stfc.ac.uk</a>, or as replies to this message.
</p>

<p>Best Regards,<br>
<strong>STFC Cloud Operations</strong></p>
23 changes: 23 additions & 0 deletions lib/apis/email_api/templates/plaintext/power_testing.txt.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Dear {{ user_name }},
This is a reminder of the upcoming planned electrical maintenance works.

These works are planned for:
- 1st-2nd November
- 8th-9th November

This will impact the following flavors, VMs using these flavors will be powered down and NOT ACCESSIBLE during the periods mentioned above:
{{ affected_flavors_table }}

We would like to request that the following VMs be shut down ahead of the first weekend, shutting down by 17:00 on Thursday 30th October 2025.
{{ affected_servers_table }}

Any impacted VMs still running will be shut down during the morning of Friday 31st October by the Cloud Operations Team.

VM access will then be restored during the afternoon of Monday 3rd November assuming all works are completed.

While the power work is expected to be completed by Monday 3rd November, we plan to operate AT RISK for the period beginning Friday 31st October, through to Wednesday 12th November.

Any questions or concerns regarding these works can be raised via our support portal, via tickets to cloud-support@stfc.ac.uk, or as replies to this message.

Best Regards,
STFC Cloud Operations
155 changes: 155 additions & 0 deletions lib/workflows/send_power_testing_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# pylint:disable=too-many-arguments
# pylint:disable=too-many-locals
from typing import List, Optional, Union

from apis.email_api.emailer import Emailer
from apis.email_api.structs.email_params import EmailParams
from apis.email_api.structs.email_template_details import EmailTemplateDetails
from apis.email_api.structs.smtp_account import SMTPAccount
from apis.openstack_api.enums.cloud_domains import CloudDomains
from tabulate import tabulate
from workflows.send_decom_flavor_email import (
find_servers_with_decom_flavors,
find_user_info,
)


def validate_input_arguments(
flavor_name_list: List[str],
from_projects: List[str] = None,
all_projects: bool = False,
):
"""
Validate input arguments for sending power testing emails.

:param flavor_name_list: List of OpenStack flavor names that are under power testing review.
:param planned_date_ranges: List of planned power testing dates associated with the flavors.
:param from_projects: (Optional) List of project names or IDs to restrict the search scope.
:param all_projects: If True, searches across all OpenStack projects.

:raises RuntimeError: If required arguments are missing or if both `from_projects` and `all_projects` are set.
"""
if not flavor_name_list:
raise RuntimeError("please provide a list of flavor names to decommission")

if from_projects and all_projects:
raise RuntimeError(
"given both project list and all_projects flag - please choose only one"
)

if not from_projects and not all_projects:
raise RuntimeError(
"please provide either a list of project identifiers or the 'all_projects' flag"
)


def build_email_params(
user_name: str,
affected_flavors_table: str,
affected_servers_table: str,
**email_kwargs,
):
"""
Construct EmailParams for notifying a user about flavors under power testing.

:param user_name: Name of the OpenStack user receiving the email.
:param affected_flavors_table: A rendered table (plain or HTML) listing affected flavors.
:param affected_servers_table: A rendered table listing the user's VMs using affected flavors.
:param email_kwargs: Additional keyword arguments for the EmailParams class.

:return: EmailParams object containing templated email content and metadata.
"""
body = EmailTemplateDetails(
template_name="power_testing",
template_params={
"user_name": user_name,
"affected_flavors_table": affected_flavors_table,
"affected_servers_table": affected_servers_table,
},
)

footer = EmailTemplateDetails(template_name="footer", template_params={})

return EmailParams(email_templates=[body, footer], **email_kwargs)


def send_power_testing_email(
smtp_account: SMTPAccount,
cloud_account: Union[CloudDomains, str],
flavor_name_list: List[str],
limit_by_projects: Optional[List[str]] = None,
all_projects: bool = False,
as_html: bool = False,
send_email: bool = False,
use_override: bool = False,
override_email_address: Optional[str] = "cloud-support@stfc.ac.uk",
cc_cloud_support: bool = False,
**email_params_kwargs,
):
"""
Notify users by email if they own VMs using flavors scheduled for power testing.

Each user receives a personalized email listing:
- Flavors under review
- Planned testing dates
- Affected VMs they own

:param smtp_account: SMTP configuration used to send the email.
:param cloud_account: Name of the OpenStack account (from clouds.yaml) to authenticate with.
:param flavor_name_list: List of flavor names that are under power testing or EOL consideration.
:param limit_by_projects: (Optional) List of projects to scope the search to (mutually exclusive with all_projects).
:param all_projects: If True, search all OpenStack projects for affected VMs.
:param as_html: If True, emails are formatted as HTML; otherwise, plain text is used.
:param send_email: If True, emails are actually sent; otherwise, the generated content is printed.
:param use_override: If True, all emails are redirected to the override email address.
:param override_email_address: Email address to use if override is enabled or if user's address is not found.
:param cc_cloud_support: If True, cc cloud-support@stfc.ac.uk on all outgoing emails.
:param email_params_kwargs: Additional arguments passed to EmailParams, such as subject or sender.
"""
validate_input_arguments(flavor_name_list, limit_by_projects, all_projects)

server_query = find_servers_with_decom_flavors(
cloud_account, flavor_name_list, limit_by_projects
)

for user_id in server_query.to_props().keys():
# if email_address not found - send to override_email_address
# also send to override_email_address if override_email set
user_name, email_addr = find_user_info(
user_id, cloud_account, override_email_address
)
send_to = [email_addr]
if use_override:
send_to = [override_email_address]

if as_html:
affected_flavors_table = tabulate(
[{"Flavor": flavor} for flavor in flavor_name_list],
headers="keys",
tablefmt="html",
)
else:
affected_flavors_table = tabulate(
[{"Flavor": flavor} for flavor in flavor_name_list],
headers="keys",
tablefmt="grid",
)
email_params = build_email_params(
user_name=user_name,
affected_flavors_table=affected_flavors_table,
affected_servers_table=(
server_query.to_string(groups=[user_id])
if not as_html
else server_query.to_html(groups=[user_id])
),
email_to=send_to,
as_html=as_html,
email_cc=("cloud-support@stfc.ac.uk",) if cc_cloud_support else None,
**email_params_kwargs,
)

if not send_email:
Emailer(smtp_account).print_email(email_params)

else:
Emailer(smtp_account).send_emails([email_params])
Loading