Skip to content

Commit 154bc51

Browse files
committed
ENH: Add emailer for fixed line testing
1 parent 7acdc3f commit 154bc51

File tree

6 files changed

+345
-10
lines changed

6 files changed

+345
-10
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
description: Sends an email to inform users about VMs running on flavors scheduled for power testing or decommissioning
2+
enabled: true
3+
entry_point: src/openstack_actions.py
4+
name: email.users.with.power.test.flavors
5+
parameters:
6+
timeout:
7+
default: 5400
8+
lib_entry_point:
9+
default: workflows.send_power_testing_email.send_power_testing_email
10+
immutable: true
11+
type: string
12+
subject:
13+
type: string
14+
description: "Subject to add to each email"
15+
required: true
16+
default: "STFC Cloud Flavor Power Testing Notice"
17+
smtp_account_name:
18+
type: string
19+
description: "Name of SMTP Account to use. Must be configured in the pack settings."
20+
required: true
21+
default: "default"
22+
cloud_account:
23+
description: "The clouds.yaml account to use whilst performing this action"
24+
required: true
25+
type: string
26+
default: "dev"
27+
enum:
28+
- "dev"
29+
- "prod"
30+
flavor_name_list:
31+
type: array
32+
description: "List of flavor names to include in the power testing notice"
33+
required: true
34+
default: null
35+
limit_by_projects:
36+
type: array
37+
description: "List of project names or IDs to limit action to - incompatible with all_projects"
38+
required: false
39+
default: null
40+
all_projects:
41+
type: boolean
42+
description: "Search across all projects - mutually exclusive with limit_by_projects"
43+
required: true
44+
default: true
45+
use_override:
46+
type: boolean
47+
description: "Redirect all emails to override address"
48+
required: true
49+
default: false
50+
as_html:
51+
type: boolean
52+
description: "Send email body as HTML"
53+
required: true
54+
default: true
55+
send_email:
56+
type: boolean
57+
description: "Actually send emails instead of just printing what would be sent"
58+
required: true
59+
default: false
60+
override_email_address:
61+
type: string
62+
description: "Override recipient email address"
63+
required: true
64+
default: "cloud-support@stfc.ac.uk"
65+
cc_cloud_support:
66+
type: boolean
67+
description: "CC cloud-support@stfc.ac.uk on all emails"
68+
required: false
69+
default: false
70+
email_from:
71+
type: string
72+
description: "Email address to send email from"
73+
required: true
74+
default: "cloud-support@stfc.ac.uk"
75+
immutable: true
76+
runner_type: python-script

lib/apis/email_api/email_template_schemas.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,10 @@ decom_capi_image:
9595
html_filepath: "html/decom_capi_image.html.j2"
9696
plaintext_filepath: "plaintext/decom_capi_image.txt.j2"
9797

98-
98+
power_testing:
99+
schema:
100+
user_name: null
101+
affected_flavors_table: "No Flavors"
102+
affected_servers_table: "No VMs affected"
103+
html_filepath: "html/power_testing.html.j2"
104+
plaintext_filepath: "plaintext/power_testing.txt.j2"

lib/apis/email_api/emailer.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1-
import time
2-
import ssl
3-
from typing import List
4-
from pathlib import Path
5-
from smtplib import SMTP
61
import logging
7-
2+
import ssl
3+
import time
84
from email.header import Header
95
from email.mime.application import MIMEApplication
106
from email.mime.multipart import MIMEMultipart
117
from email.mime.text import MIMEText
128
from email.utils import formatdate
13-
14-
from apis.email_api.template_handler import TemplateHandler
9+
from pathlib import Path
10+
from smtplib import SMTP
11+
from typing import List
1512

1613
from apis.email_api.structs.email_params import EmailParams
17-
from apis.email_api.structs.smtp_account import SMTPAccount
1814
from apis.email_api.structs.email_template_details import EmailTemplateDetails
15+
from apis.email_api.structs.smtp_account import SMTPAccount
16+
from apis.email_api.template_handler import TemplateHandler
1917

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

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

91+
def print_email(self, email_params: EmailParams):
92+
"""
93+
Print a well-formatted version of the email that would be sent.
94+
Useful for debugging or dry-run previews.
95+
"""
96+
# Build the MIME email using the same function that send_email uses
97+
msg = self.build_email(email_params)
98+
99+
# Display email headers
100+
print("===== EMAIL PREVIEW =====")
101+
print(f"From : {msg['From']}")
102+
print(f"To : {msg['To']}")
103+
print(f"Cc : {msg['Cc'] or '<none>'}")
104+
print(f"Subject: {msg['Subject']}")
105+
print(f"Date : {msg['Date']}")
106+
print(f"Reply-To: {msg['reply-to']}")
107+
print("\n--- Body ---")
108+
109+
# Extract and print the body parts (could be plain or HTML)
110+
for part in msg.walk():
111+
content_type = part.get_content_type()
112+
content_disposition = part.get("Content-Disposition", "").lower()
113+
if (
114+
content_type.startswith("text/")
115+
and "attachment" not in content_disposition
116+
):
117+
charset = part.get_content_charset() or "utf-8"
118+
body = part.get_payload(decode=True).decode(charset, errors="replace")
119+
print(body)
120+
print("\n--- End of Body ---\n")
121+
122+
# List any attachments
123+
attachments = []
124+
for part in msg.walk():
125+
content_disposition = part.get("Content-Disposition", "").lower()
126+
if "attachment" in content_disposition:
127+
filename = part.get_filename()
128+
attachments.append(filename)
129+
130+
if attachments:
131+
print("--- Attachments ---")
132+
for filename in attachments:
133+
print(f"- {filename}")
134+
else:
135+
print("No attachments.")
136+
137+
print("=========================\n")
138+
93139
def build_email(self, email_params: EmailParams) -> MIMEMultipart:
94140
"""
95141
Helper function to setup email as MIMEMultipart
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<p>Dear {{ user_name }},</p>
2+
3+
<p>This is a reminder of the upcoming planned <strong>electrical maintenance works</strong>.</p>
4+
5+
<p><strong>These works are planned for:</strong><br>
6+
<ul>
7+
<li>1st-2nd November</li>
8+
<li>8th-9th November</li>
9+
</ul></p>
10+
11+
<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>
12+
{{ affected_flavors_table }}</p>
13+
14+
<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>
15+
{{ affected_servers_table }}</p>
16+
17+
<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>
18+
19+
<p><strong>VM access will then be restored</strong> during the afternoon of <strong>Monday 3rd November</strong>, assuming all works are completed.</p>
20+
21+
<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>
22+
23+
<p>Any questions or concerns regarding these works can be raised via our support portal, via tickets to
24+
<a href="mailto:cloud-support@stfc.ac.uk">cloud-support@stfc.ac.uk</a>, or as replies to this message.
25+
</p>
26+
27+
<p>Best Regards,<br>
28+
<strong>STFC Cloud Operations</strong></p>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Dear {{ user_name }},
2+
This is a reminder of the upcoming planned electrical maintenance works.
3+
4+
These works are planned for:
5+
- 1st-2nd November
6+
- 8th-9th November
7+
8+
This will impact the following flavors, VMs using these flavors will be powered down and NOT ACCESSIBLE during the periods mentioned above:
9+
{{ affected_flavors_table }}
10+
11+
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.
12+
{{ affected_servers_table }}
13+
14+
Any impacted VMs still running will be shut down during the morning of Friday 31st October by the Cloud Operations Team.
15+
16+
VM access will then be restored during the afternoon of Monday 3rd November assuming all works are completed.
17+
18+
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.
19+
20+
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.
21+
22+
Best Regards,
23+
STFC Cloud Operations
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# pylint:disable=too-many-arguments
2+
# pylint:disable=too-many-locals
3+
from email.header import Header
4+
from typing import List, Optional, Union
5+
6+
from apis.email_api.emailer import Emailer
7+
from apis.email_api.structs.email_params import EmailParams
8+
from apis.email_api.structs.email_template_details import EmailTemplateDetails
9+
from apis.email_api.structs.smtp_account import SMTPAccount
10+
from apis.openstack_api.enums.cloud_domains import CloudDomains
11+
from tabulate import tabulate
12+
from workflows.send_decom_flavor_email import (
13+
find_servers_with_decom_flavors,
14+
find_user_info,
15+
)
16+
17+
18+
def validate_input_arguments(
19+
flavor_name_list: List[str],
20+
from_projects: List[str] = None,
21+
all_projects: bool = False,
22+
):
23+
"""
24+
Validate input arguments for sending power testing emails.
25+
26+
:param flavor_name_list: List of OpenStack flavor names that are under power testing review.
27+
:param planned_date_ranges: List of planned power testing dates associated with the flavors.
28+
:param from_projects: (Optional) List of project names or IDs to restrict the search scope.
29+
:param all_projects: If True, searches across all OpenStack projects.
30+
31+
:raises RuntimeError: If required arguments are missing or if both `from_projects` and `all_projects` are set.
32+
"""
33+
if not flavor_name_list:
34+
raise RuntimeError("please provide a list of flavor names to decommission")
35+
36+
if from_projects and all_projects:
37+
raise RuntimeError(
38+
"given both project list and all_projects flag - please choose only one"
39+
)
40+
41+
if not from_projects and not all_projects:
42+
raise RuntimeError(
43+
"please provide either a list of project identifiers or the 'all_projects' flag"
44+
)
45+
46+
47+
def build_email_params(
48+
user_name: str,
49+
affected_flavors_table: str,
50+
affected_servers_table: str,
51+
**email_kwargs,
52+
):
53+
"""
54+
Construct EmailParams for notifying a user about flavors under power testing.
55+
56+
:param user_name: Name of the OpenStack user receiving the email.
57+
:param affected_flavors_table: A rendered table (plain or HTML) listing affected flavors.
58+
:param affected_servers_table: A rendered table listing the user's VMs using affected flavors.
59+
:param email_kwargs: Additional keyword arguments for the EmailParams class.
60+
61+
:return: EmailParams object containing templated email content and metadata.
62+
"""
63+
body = EmailTemplateDetails(
64+
template_name="power_testing",
65+
template_params={
66+
"user_name": user_name,
67+
"affected_flavors_table": affected_flavors_table,
68+
"affected_servers_table": affected_servers_table,
69+
},
70+
)
71+
72+
footer = EmailTemplateDetails(template_name="footer", template_params={})
73+
74+
return EmailParams(email_templates=[body, footer], **email_kwargs)
75+
76+
77+
def send_power_testing_email(
78+
smtp_account: SMTPAccount,
79+
cloud_account: Union[CloudDomains, str],
80+
flavor_name_list: List[str],
81+
limit_by_projects: Optional[List[str]] = None,
82+
all_projects: bool = False,
83+
as_html: bool = False,
84+
send_email: bool = False,
85+
use_override: bool = False,
86+
override_email_address: Optional[str] = "cloud-support@stfc.ac.uk",
87+
cc_cloud_support: bool = False,
88+
**email_params_kwargs,
89+
):
90+
"""
91+
Notify users by email if they own VMs using flavors scheduled for power testing.
92+
93+
Each user receives a personalized email listing:
94+
- Flavors under review
95+
- Planned testing dates
96+
- Affected VMs they own
97+
98+
:param smtp_account: SMTP configuration used to send the email.
99+
:param cloud_account: Name of the OpenStack account (from clouds.yaml) to authenticate with.
100+
:param flavor_name_list: List of flavor names that are under power testing or EOL consideration.
101+
:param limit_by_projects: (Optional) List of projects to scope the search to (mutually exclusive with all_projects).
102+
:param all_projects: If True, search all OpenStack projects for affected VMs.
103+
:param as_html: If True, emails are formatted as HTML; otherwise, plain text is used.
104+
:param send_email: If True, emails are actually sent; otherwise, the generated content is printed.
105+
:param use_override: If True, all emails are redirected to the override email address.
106+
:param override_email_address: Email address to use if override is enabled or if user's address is not found.
107+
:param cc_cloud_support: If True, cc cloud-support@stfc.ac.uk on all outgoing emails.
108+
:param email_params_kwargs: Additional arguments passed to EmailParams, such as subject or sender.
109+
"""
110+
validate_input_arguments(flavor_name_list, limit_by_projects, all_projects)
111+
112+
server_query = find_servers_with_decom_flavors(
113+
cloud_account, flavor_name_list, limit_by_projects
114+
)
115+
116+
for user_id in server_query.to_props().keys():
117+
# if email_address not found - send to override_email_address
118+
# also send to override_email_address if override_email set
119+
user_name, email_addr = find_user_info(
120+
user_id, cloud_account, override_email_address
121+
)
122+
send_to = [email_addr]
123+
if use_override:
124+
send_to = [override_email_address]
125+
126+
if as_html:
127+
affected_flavors_table = tabulate(
128+
[{"Flavor": flavor} for flavor in flavor_name_list],
129+
headers="keys",
130+
tablefmt="html",
131+
)
132+
else:
133+
affected_flavors_table = tabulate(
134+
[{"Flavor": flavor} for flavor in flavor_name_list],
135+
headers="keys",
136+
tablefmt="grid",
137+
)
138+
email_params = build_email_params(
139+
user_name=user_name,
140+
affected_flavors_table=affected_flavors_table,
141+
affected_servers_table=(
142+
server_query.to_string(groups=[user_id])
143+
if not as_html
144+
else server_query.to_html(groups=[user_id])
145+
),
146+
email_to=send_to,
147+
as_html=as_html,
148+
email_cc=("cloud-support@stfc.ac.uk",) if cc_cloud_support else None,
149+
**email_params_kwargs,
150+
)
151+
152+
if not send_email:
153+
Emailer(smtp_account).print_email(email_params)
154+
155+
else:
156+
Emailer(smtp_account).send_emails([email_params])

0 commit comments

Comments
 (0)