Skip to content

Commit eb08ab7

Browse files
committed
feature(argus): SCT Email Reporting
This commit adds a new service which allows argus to replace SCT send_email functionality with its own email API, providing consumers with a way to use various partial templates and compose them into the standard SCT email report. It also provides an extensible class hiearchy for defining custom templates, as well as several basic templates that mirror standard longevity report. Fixes #650
1 parent 0830253 commit eb08ab7

24 files changed

+1314
-7
lines changed

argus/backend/controller/client_api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from flask import Blueprint, request
22

33
from argus.backend.error_handlers import handle_api_exception
4+
from argus.backend.service.email_service import EmailService
45
from argus.backend.service.testrun import TestRunService
56
from argus.backend.service.user import api_login_required
67
from argus.backend.service.client_service import ClientService
@@ -138,3 +139,22 @@ def get_pytest_test_field_stats(test_name: str, field_name: str, aggr_function:
138139
"status": "ok",
139140
"response": result
140141
}
142+
143+
144+
@bp.route("/testrun/report/email", methods=["POST"])
145+
@api_login_required
146+
def send_email_report():
147+
payload = get_payload(request)
148+
result = EmailService().send_report(request_data=payload)
149+
return {
150+
"status": "ok",
151+
"response": result
152+
}
153+
154+
155+
@bp.route("/testrun/report", methods=["POST"])
156+
@api_login_required
157+
def render_email_report():
158+
payload = get_payload(request)
159+
result = EmailService().display_report(request_data=payload)
160+
return result
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import base64
2+
from dataclasses import asdict, dataclass
3+
from io import BytesIO
4+
import logging
5+
from typing import Any
6+
7+
from flask import render_template
8+
import humanize
9+
from argus.backend.plugins.sct.testrun import SCTEventSeverity, SCTTestRun
10+
from argus.backend.util.common import get_build_number
11+
from argus.backend.util.send_email import Attachment, Email
12+
from argus.common.email import RawAttachment, RawReportSendRequest, ReportSection, ReportSectionShortHand
13+
14+
15+
LOGGER = logging.getLogger(__name__)
16+
17+
class GmailSender(Email):
18+
pass
19+
20+
21+
@dataclass(init=True, repr=True)
22+
class ReportSendRequest():
23+
schema_version: str | None
24+
run_id: str
25+
title: str
26+
recipients: list[str]
27+
sections: list[ReportSection | ReportSectionShortHand]
28+
attachments: list[RawAttachment]
29+
30+
31+
class Partial():
32+
TEMPLATE_PATH = "#PATH"
33+
34+
def __init__(self, section_type: str, test_run: SCTTestRun):
35+
self.test_run = test_run
36+
self.section_type = section_type
37+
self._service_fields = {
38+
"template_path": self.TEMPLATE_PATH,
39+
"type": self.section_type,
40+
"has_data": True,
41+
}
42+
43+
def create_context(self, options: dict[str, Any]) -> dict[str, Any]:
44+
raise NotImplementedError()
45+
46+
def default_options(self) -> dict[str, Any]:
47+
raise NotImplementedError()
48+
49+
50+
class Header(Partial):
51+
TEMPLATE_PATH = "email/partials/header.html.j2"
52+
def create_context(self, options: dict[str, Any]):
53+
return {
54+
**self.default_options(),
55+
}
56+
57+
def default_options(self):
58+
return {
59+
**self._service_fields,
60+
"id": self.test_run.id,
61+
"build_id": self.test_run.build_id,
62+
"build_number": get_build_number(self.test_run.build_job_url),
63+
"status": self.test_run.status,
64+
}
65+
66+
67+
class Main(Partial):
68+
TEMPLATE_PATH = "email/partials/main.html.j2"
69+
70+
def create_context(self, options: dict[str, Any]) -> dict[str, Any]:
71+
return {
72+
**self.default_options(),
73+
}
74+
75+
def default_options(self) -> dict[str, Any]:
76+
return {
77+
**self._service_fields,
78+
"started_by": self.test_run.started_by,
79+
"end_time": self.test_run.end_time,
80+
"start_time": self.test_run.start_time,
81+
"duration": humanize.naturaldelta(self.test_run.end_time - self.test_run.start_time),
82+
"build_job_url": self.test_run.build_job_url,
83+
"run_id": self.test_run.id,
84+
"packages": self.test_run.packages,
85+
"status": self.test_run.status,
86+
"commit": self.test_run.scm_revision_id,
87+
"branch": self.test_run.branch_name,
88+
"repo": self.test_run.origin_url,
89+
"cloud_setup": self.test_run.cloud_setup,
90+
}
91+
92+
93+
class Packages(Partial):
94+
TEMPLATE_PATH = "email/partials/packages.html.j2"
95+
def create_context(self, options: dict[str, Any]):
96+
return {
97+
**self.default_options(),
98+
"has_data": len(self.test_run.packages) > 0,
99+
}
100+
101+
def default_options(self):
102+
return {
103+
**self._service_fields,
104+
"run_id": self.test_run.id,
105+
"packages": [dict(p) for p in self.test_run.packages],
106+
}
107+
108+
109+
110+
class Logs(Partial):
111+
TEMPLATE_PATH = "email/partials/logs.html.j2"
112+
def create_context(self, options: dict[str, Any]):
113+
return {
114+
**self.default_options(),
115+
"has_data": len(self.test_run.logs) > 0,
116+
}
117+
118+
def default_options(self):
119+
create_link = lambda log: f"/api/v1/tests/scylla-cluster-tests/{self.test_run.id}/log/{log[0]}/download"
120+
proxy_links = { log[0]: create_link(log) for log in self.test_run.logs }
121+
return {
122+
**self._service_fields,
123+
"run_id": self.test_run.id,
124+
"logs": self.test_run.logs,
125+
"proxied_links": proxy_links,
126+
}
127+
128+
129+
class Screenshots(Partial):
130+
TEMPLATE_PATH = "email/partials/screenshots.html.j2"
131+
def create_context(self, options: dict[str, Any]):
132+
return {
133+
**self.default_options(),
134+
"has_data": len(self.test_run.screenshots) > 0,
135+
}
136+
137+
def default_options(self):
138+
return {
139+
**self._service_fields,
140+
"run_id": self.test_run.id,
141+
"links": self.test_run.screenshots,
142+
}
143+
144+
145+
class Cloud(Partial):
146+
TEMPLATE_PATH = "email/partials/cloud.html.j2"
147+
def create_context(self, options: dict[str, Any]):
148+
resources = list(filter(lambda res: res.resource_type != "sct-runner", self.test_run.get_resources()))
149+
return {
150+
**self.default_options(),
151+
"has_data": len(list(filter(lambda r: r.state == "running", resources))) > 0,
152+
"resources": resources,
153+
}
154+
155+
def default_options(self):
156+
return {
157+
**self._service_fields,
158+
"run_id": self.test_run.id,
159+
"cloud_setup": self.test_run.cloud_setup,
160+
}
161+
162+
163+
class Nemesis(Partial):
164+
TEMPLATE_PATH = "email/partials/nemesis.html.j2"
165+
def create_context(self, options: dict[str, Any]):
166+
status_filter = options.get("status_filter") or ["failed", "succeeded"]
167+
nemesis = list(filter(lambda nem: nem.status in status_filter, self.test_run.nemesis_data))
168+
return {
169+
**self.default_options(),
170+
"run_id": self.test_run.id,
171+
"sort_order": options.get("sort_order") or ["start_time", "desc"],
172+
"status_filter": status_filter,
173+
"has_data": len(nemesis) > 0,
174+
"nemesis": nemesis,
175+
}
176+
177+
def default_options(self):
178+
return {
179+
**self._service_fields,
180+
}
181+
182+
183+
class Events(Partial):
184+
TEMPLATE_PATH = "email/partials/events.html.j2"
185+
def create_context(self, options: dict[str, Any]):
186+
severities = [SCTEventSeverity(s) for s in options.get("severity_filter", ["CRITICAL", "ERROR"])]
187+
limit = options.get("amount_per_severity", 25)
188+
events = self.test_run.get_events_limited(self.test_run.id, severities=severities, per_partition_limit=limit)
189+
return {
190+
**self.default_options(),
191+
"events": events,
192+
}
193+
194+
def default_options(self):
195+
return {
196+
**self._service_fields,
197+
"run_id": self.test_run.id,
198+
}
199+
200+
201+
202+
class Unsupported(Partial):
203+
TEMPLATE_PATH = "email/partials/unsupported.html.j2"
204+
def create_context(self, options: dict[str, Any]):
205+
return {
206+
**self.default_options(),
207+
**options
208+
}
209+
210+
def default_options(self):
211+
return {
212+
**self._service_fields,
213+
}
214+
215+
216+
class CustomHtml(Partial):
217+
TEMPLATE_PATH = "email/partials/custom_html.html.j2"
218+
def create_context(self, options: dict[str, Any]):
219+
return {
220+
**self.default_options(),
221+
**options,
222+
}
223+
224+
def default_options(self):
225+
return {
226+
**self._service_fields,
227+
"run_id": self.test_run.id,
228+
}
229+
230+
231+
class CustomTable(Partial):
232+
TEMPLATE_PATH = "email/partials/custom_table.html.j2"
233+
def create_context(self, options: dict[str, Any]):
234+
return {
235+
**self.default_options(),
236+
**options,
237+
}
238+
239+
def default_options(self):
240+
return {
241+
**self._service_fields,
242+
"run_id": self.test_run.id,
243+
}
244+
245+
246+
PARTIALS: dict[str, Partial] = {
247+
"main": Main,
248+
"header": Header,
249+
"packages": Packages,
250+
"logs": Logs,
251+
"cloud": Cloud,
252+
"nemesis": Nemesis,
253+
"events": Events,
254+
"screenshots": Screenshots,
255+
"custom_table": CustomTable,
256+
"custom_html": CustomHtml,
257+
"unsupported": Unsupported,
258+
}
259+
260+
261+
DEFAULT_SECTIONS = [
262+
"header",
263+
"main",
264+
"packages",
265+
"screenshots",
266+
"cloud",
267+
{
268+
"type": "events",
269+
"options": {
270+
"amount_per_severity": 25,
271+
"severity_filter": [
272+
"CRITICAL",
273+
"ERROR"
274+
]
275+
}
276+
},
277+
{
278+
"type": "nemesis",
279+
"options": {
280+
"sort_order": [
281+
"start_time",
282+
"desc"
283+
],
284+
"status_filter": [
285+
"failed",
286+
"succeeded",
287+
"started",
288+
"running"
289+
]
290+
}
291+
},
292+
"logs",
293+
]
294+
295+
296+
class EmailServiceException(Exception):
297+
pass
298+
299+
300+
class EmailService:
301+
SENDER = None
302+
303+
@classmethod
304+
def set_sender(cls, sender: Email):
305+
cls.SENDER = sender
306+
307+
def __init__(self, sender: Email = None):
308+
if not self.SENDER:
309+
self.sender = sender if sender else GmailSender()
310+
else:
311+
self.sender = self.SENDER
312+
313+
def send_report(self, request_data: RawReportSendRequest) -> bool:
314+
req = ReportSendRequest(**request_data)
315+
try:
316+
report = self.create_report(req)
317+
except Exception as exc:
318+
raise EmailServiceException("Error during template render", exc.args)
319+
attachments = []
320+
for raw_attach in req.attachments:
321+
data_io = BytesIO(base64.decodebytes(raw_attach["data"].encode()))
322+
attachment: Attachment = {
323+
"filename": raw_attach["filename"],
324+
"data": data_io,
325+
}
326+
attachments.append(attachment)
327+
try:
328+
self.sender.send(req.title, report, recipients=req.recipients, html=True, attachments=attachments)
329+
except Exception as exc:
330+
raise EmailServiceException("Error sending email report", exc.args)
331+
return True
332+
333+
def display_report(self, request_data: RawReportSendRequest) -> str:
334+
req = ReportSendRequest(**request_data)
335+
return self.create_report(req)
336+
337+
def create_report(self, request: ReportSendRequest) -> str:
338+
run: SCTTestRun = SCTTestRun.get(id=request.run_id)
339+
partials = []
340+
for section in request.sections if len(request.sections) > 0 else DEFAULT_SECTIONS:
341+
if isinstance(section, dict):
342+
partial = PARTIALS.get(section["type"], PARTIALS["unsupported"])(section_type=section["type"], test_run=run)
343+
partials.append(partial.create_context(section["options"]))
344+
elif isinstance(section, str):
345+
partial = PARTIALS.get(section, PARTIALS["unsupported"])(section_type=section, test_run=run)
346+
partials.append(partial.create_context({}))
347+
if request.title == "#auto":
348+
request.title = f"[{run.status.upper()}] {run.build_id}#{get_build_number(run.build_job_url)}: {run.start_time.strftime("%d/%m/%Y %H:%M:%S")}"
349+
request.sections = partials
350+
return render_template("email/base.html.j2", **asdict(request), run=run)

0 commit comments

Comments
 (0)