-
Notifications
You must be signed in to change notification settings - Fork 19
feat(notifications): add hardware_summary action #1610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Add a subscription file for your hardware platform by following this format. | ||
| # Place the file under: dashboard/backend/data/notifications/subscriptions | ||
| # The file name must end with _hardware.yml or _hardware.yaml | ||
|
|
||
| qcs9100-ride: # The hardware platform name | ||
| origin: maestro | ||
| default_recipients: | ||
| - Recipient One <[email protected]> | ||
| - Recipient Two <[email protected]> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| qcs615-ride: | ||
| origin: maestro | ||
| default_recipients: | ||
| - Trilok Soni <[email protected]> | ||
| - Shiraz Hashim <[email protected]> | ||
| - Yogesh Lal <[email protected]> | ||
| - Yijie Yang <[email protected]> | ||
| - Yushan Li <[email protected]> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| qcs6490-rb3gen2: | ||
| origin: maestro | ||
| default_recipients: | ||
| - Trilok Soni <[email protected]> | ||
| - Shiraz Hashim <[email protected]> | ||
| - Yogesh Lal <[email protected]> | ||
| - Yijie Yang <[email protected]> | ||
| - Yushan Li <[email protected]> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| qcs8300-ride: | ||
| origin: maestro | ||
| default_recipients: | ||
| - Trilok Soni <[email protected]> | ||
| - Shiraz Hashim <[email protected]> | ||
| - Yogesh Lal <[email protected]> | ||
| - Yijie Yang <[email protected]> | ||
| - Yushan Li <[email protected]> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| qcs9100-ride: | ||
| origin: maestro | ||
| default_recipients: | ||
| - Trilok Soni <[email protected]> | ||
| - Shiraz Hashim <[email protected]> | ||
| - Yogesh Lal <[email protected]> | ||
| - Yijie Yang <[email protected]> | ||
| - Yushan Li <[email protected]> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| x1e80100: | ||
| origin: maestro | ||
| default_recipients: | ||
| - Trilok Soni <[email protected]> | ||
| - Shiraz Hashim <[email protected]> | ||
| - Yogesh Lal <[email protected]> | ||
| - Yijie Yang <[email protected]> | ||
| - Yushan Li <[email protected]> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| from kernelCI_app.typeModels.common import StatusCount | ||
| from kernelCI_app.typeModels.hardwareListing import HardwareItem | ||
|
|
||
|
|
||
| def sanitize_hardware( | ||
| hardware: dict, | ||
| ) -> HardwareItem: | ||
| """Sanitizes a HardwareItem that was returned by a 'hardwarelisting-like' query | ||
|
|
||
| Returns a HardwareItem object""" | ||
| hardware_name = hardware["hardware"] | ||
| platform = hardware["platform"] | ||
|
|
||
| build_status_summary = StatusCount( | ||
| PASS=hardware["pass_builds"], | ||
| FAIL=hardware["fail_builds"], | ||
| NULL=hardware["null_builds"], | ||
| ERROR=hardware["error_builds"], | ||
| MISS=hardware["miss_builds"], | ||
| DONE=hardware["done_builds"], | ||
| SKIP=hardware["skip_builds"], | ||
| ) | ||
|
|
||
| test_status_summary = StatusCount( | ||
| PASS=hardware["pass_tests"], | ||
| FAIL=hardware["fail_tests"], | ||
| NULL=hardware["null_tests"], | ||
| ERROR=hardware["error_tests"], | ||
| MISS=hardware["miss_tests"], | ||
| DONE=hardware["done_tests"], | ||
| SKIP=hardware["skip_tests"], | ||
| ) | ||
|
|
||
| boot_status_summary = StatusCount( | ||
| PASS=hardware["pass_boots"], | ||
| FAIL=hardware["fail_boots"], | ||
| NULL=hardware["null_boots"], | ||
| ERROR=hardware["error_boots"], | ||
| MISS=hardware["miss_boots"], | ||
| DONE=hardware["done_boots"], | ||
| SKIP=hardware["skip_boots"], | ||
| ) | ||
|
|
||
| return HardwareItem( | ||
| hardware=hardware_name, | ||
| platform=platform, | ||
| test_status_summary=test_status_summary, | ||
| boot_status_summary=boot_status_summary, | ||
| build_status_summary=build_status_summary, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,9 @@ | |
| type TreeKey = tuple[str, str, str] | ||
| """A tuple (branch, giturl, origin)""" | ||
|
|
||
| type HardwareKey = tuple[str, str] | ||
| """A tuple (hardware, origin)""" | ||
|
|
||
| type ReportConfigs = list[dict[str, Any]] | ||
| """A list of dictionaries containing the definition/configuration of a report""" | ||
|
|
||
|
|
@@ -163,3 +166,59 @@ def get_build_issues_from_checkout( | |
| result_checkout_issues[checkout_id].append(checkout_issue) | ||
|
|
||
| return result_checkout_issues, checkout_builds_without_issues | ||
|
|
||
|
|
||
| def process_hardware_submissions_files( | ||
| *, | ||
| base_dir: Optional[str] = None, | ||
| signup_folder: Optional[str] = None, | ||
| hardware_origins: Optional[list[str]] = None, | ||
| ) -> tuple[set[HardwareKey], dict[HardwareKey, ReportConfigs]]: | ||
| """Processes all hardware submission files and returns the set of HardwareKey and | ||
| the dict linking each hardware to its report props""" | ||
| (base_dir, signup_folder) = _assign_default_folders( | ||
| base_dir=base_dir, signup_folder=signup_folder | ||
| ) | ||
|
|
||
| hardware_key_set: set[HardwareKey] = set() | ||
| hardware_prop_map: dict[HardwareKey, ReportConfigs] = {} | ||
| """Example: | ||
| hardware_prop_map[(imx6q-sabrelite, maestro)] = [ | ||
| { | ||
| default_recipients: [] | ||
| }, | ||
| ... | ||
| ] | ||
| """ | ||
|
|
||
| full_path = os.path.join(base_dir, signup_folder) | ||
| for filename in os.listdir(full_path): | ||
| """Example: | ||
| Filename: imx6q_hardware.yaml | ||
|
|
||
| imx6q-sabrelite: | ||
| origin: maestro | ||
| default_recipients: | ||
| - Recipient One <[email protected]> | ||
| - Recipient Two <[email protected]> | ||
| """ | ||
| if filename.endswith("_hardware.yaml") or filename.endswith("_hardware.yml"): | ||
| file_path = os.path.join(signup_folder, filename) | ||
| file_data = read_yaml_file(base_dir=base_dir, file=file_path) | ||
| for hardware_name, hardware_values in file_data.items(): | ||
| origin = hardware_values.get("origin", DEFAULT_ORIGIN) | ||
| if hardware_origins is not None and origin not in hardware_origins: | ||
| continue | ||
| default_recipients = hardware_values.get("default_recipients", []) | ||
|
|
||
| hardware_key = (hardware_name, origin) | ||
| hardware_key_set.add(hardware_key) | ||
| if hardware_prop_map.get(hardware_key) is None: | ||
| hardware_prop_map[hardware_key] = [] | ||
| hardware_prop_map[hardware_key].append({"default_recipients": default_recipients}) | ||
| else: | ||
| log_message( | ||
| f"Skipping file {filename} on loading summary files. Not a hardware yaml file." | ||
| ) | ||
|
|
||
| return hardware_key_set, hardware_prop_map | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| import sys | ||
|
|
||
| from collections import defaultdict | ||
| from datetime import datetime, timezone | ||
| from datetime import datetime, timezone, timedelta | ||
| from types import SimpleNamespace | ||
| from urllib.parse import quote_plus | ||
|
|
||
|
|
@@ -29,6 +29,7 @@ | |
| TreeKey, | ||
| get_build_issues_from_checkout, | ||
| process_submissions_files, | ||
| process_hardware_submissions_files, | ||
| ) | ||
| from kernelCI_app.queries.notifications import ( | ||
| get_checkout_summary_data, | ||
|
|
@@ -55,6 +56,9 @@ | |
| from kernelCI_cache.typeModels.databases import PossibleIssueType | ||
| from kernelCI_app.utils import group_status | ||
|
|
||
| from kernelCI_app.queries.hardware import get_hardware_summary_data, get_hardware_listing_data_bulk | ||
| from kernelCI_app.helpers.hardwares import sanitize_hardware | ||
|
|
||
| KERNELCI_RESULTS = "[email protected]" | ||
| KERNELCI_REPLYTO = "[email protected]" | ||
| REGRESSIONS_LIST = "[email protected]" | ||
|
|
@@ -723,6 +727,93 @@ def generate_test_report(*, service, test_id, email_args, signup_folder): | |
| ) | ||
|
|
||
|
|
||
| def generate_hardware_summary_report( | ||
| *, | ||
| service, | ||
| hardware_origins: Optional[list[str]], | ||
| email_args, | ||
| signup_folder: Optional[str] = None, | ||
| ): | ||
| """Generate monthly hardware reports for hardware submission file.""" | ||
|
|
||
| now = datetime.now(timezone.utc) | ||
| start_date = now - timedelta(days=30) | ||
| end_date = now | ||
|
|
||
| # process the hardware submission files | ||
| hardware_key_set, hardware_prop_map = process_hardware_submissions_files( | ||
| signup_folder=signup_folder, | ||
| hardware_origins=hardware_origins, | ||
| ) | ||
|
|
||
| # get detailed data for all hardware | ||
| hardwares_data_raw = get_hardware_summary_data( | ||
| keys=list(hardware_key_set), | ||
| start_date=start_date, | ||
| end_date=end_date, | ||
| ) | ||
|
Comment on lines
+750
to
+754
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know that usually there will be results, but just for precaution I would add a if not hardwares_data_raw:
print("No data for hardware summary")
returnso that the command can return gracefully |
||
| hardwares_data_dict = defaultdict(list) | ||
| for raw in hardwares_data_raw: | ||
| try: | ||
| environment_misc = json.loads(raw.get("environment_misc", "{}")) | ||
| except json.JSONDecodeError: | ||
| print(f'Error decoding JSON for key: {raw.get("environment_misc")}') | ||
| continue | ||
| hardware_id = environment_misc.get("platform") | ||
| origin = raw.get("test_origin") | ||
| key = (hardware_id, origin) | ||
| hardwares_data_dict[key].append(raw) | ||
|
|
||
| # get the total build/boot/test counts for each hardware | ||
| hardwares_list_raw = get_hardware_listing_data_bulk( | ||
| keys=list(hardware_key_set), | ||
| start_date=start_date, | ||
| end_date=end_date, | ||
| ) | ||
|
|
||
| # Iterate through each hardware record to render report, extract recipient, send email | ||
| for (hardware_id, origin), hardware_data in hardwares_data_dict.items(): | ||
| hardware_raw = next( | ||
| (row for row in hardwares_list_raw if row.get("platform") == hardware_id), | ||
| None | ||
| ) | ||
|
|
||
| hardware_item = sanitize_hardware(hardware_raw) | ||
| build_status_group = group_status(hardware_item.build_status_summary) | ||
| boot_status_group = group_status(hardware_item.boot_status_summary) | ||
| test_status_group = group_status(hardware_item.test_status_summary) | ||
|
|
||
| # render the template | ||
| template = setup_jinja_template("hardware_report.txt.j2") | ||
| report = {} | ||
| report["content"] = template.render( | ||
| hardware_id=hardware_id, | ||
| hardware_data=hardware_data, | ||
| build_status_group=build_status_group, | ||
| boot_status_group=boot_status_group, | ||
| test_status_group=test_status_group | ||
| ) | ||
| report["title"] = f"hardware {hardware_id} summary - {now.strftime("%Y-%m-%d %H:%M %Z")}" | ||
|
|
||
| # extract recipient | ||
| for (hardware_id, origin), report_configs in hardware_prop_map.items(): | ||
| for hardware_report in report_configs: | ||
| recipients = process_submission_options( | ||
| default_recipients=hardware_report.get("default_recipients", []), | ||
| specific_recipients=hardware_report.get("recipients", []), | ||
| options=hardware_report.get("options", []), | ||
| ) | ||
|
Comment on lines
+798
to
+805
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't this block be getting only the recipient for the specific hardware in this loop? it seems like it is getting the recipients for all reports and using only the last one. Also, since you already have |
||
|
|
||
| # send email | ||
| send_email_report( | ||
| service=service, | ||
| report=report, | ||
| email_args=email_args, | ||
| signup_folder=signup_folder, | ||
| recipients=recipients | ||
| ) | ||
|
|
||
|
|
||
| def run_fake_report(*, service, email_args): | ||
| report = {} | ||
| report["content"] = "Testing the email sending path..." | ||
|
|
@@ -787,6 +878,7 @@ def add_arguments(self, parser): | |
| "summary", | ||
| "fake_report", | ||
| "test_report", | ||
| "hardware_summary", | ||
| ], | ||
| help="Action to perform: new_issues, issue_report, summary, fake_report, or test_report", | ||
| ) | ||
|
|
@@ -841,6 +933,13 @@ def add_arguments(self, parser): | |
| help="Add recipients for the given tree name (fake_report only)", | ||
| ) | ||
|
|
||
| # hardware summary report specific arguments | ||
| parser.add_argument( | ||
| "--hardware-origins", | ||
| type=lambda s: [origin.strip() for origin in s.split(",")], | ||
| help="Limit hardware summary to specific origins (hardware summary only, comma-separated list)", | ||
| ) | ||
|
|
||
| def handle(self, *args, **options): | ||
| # Setup connections | ||
| service = smtp_setup_connection() | ||
|
|
@@ -929,3 +1028,13 @@ def handle(self, *args, **options): | |
| self.stdout.write( | ||
| self.style.SUCCESS(f"Test report generated for test {test_id}") | ||
| ) | ||
|
|
||
| elif action == "hardware_summary": | ||
| email_args.update = options.get("update_storage", False) | ||
| hardware_origins = options.get("hardware_origins") | ||
| generate_hardware_summary_report( | ||
| service=service, | ||
| signup_folder=signup_folder, | ||
| email_args=email_args, | ||
| hardware_origins=hardware_origins, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| {% extends "base.txt" %} | ||
| {% block header %}{% endblock %} | ||
| {% block content %} | ||
| Hello, | ||
|
|
||
| Status summary for {{ hardware_id }} | ||
|
|
||
| Builds:{{ "\t" }}{{ "{:>5}".format(build_status_group["success"]) }} ✅ | ||
| {{- "{:>5}".format(build_status_group["failed"]) }} ❌ | ||
| {{- "{:>5}".format(build_status_group["inconclusive"]) }} ⚠️ | ||
| Boots: {{ "\t" }}{{ "{:>5}".format(boot_status_group["success"]) }} ✅ | ||
| {{- "{:>5}".format(boot_status_group["failed"]) }} ❌ | ||
| {{- "{:>5}".format(boot_status_group["inconclusive"]) }} ⚠️ | ||
| Tests: {{ "\t" }}{{ "{:>5}".format(test_status_group["success"]) }} ✅ | ||
| {{- "{:>5}".format(test_status_group["failed"]) }} ❌ | ||
| {{- "{:>5}".format(test_status_group["inconclusive"]) }} ⚠️ | ||
| ------------- | ||
| {% for data in hardware_data %} | ||
| #kernelci test {{ data["id"] }} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Depends on what you want, but would it make sense to show the test path here, instead of the id? |
||
| - Status: {{ data["status"] }} | ||
| - Comment: {{ data["comment"] }} | ||
| - Starttime: {{ data["start_time"] }} | ||
| - Tree: {{ data["build__checkout__tree_name"] }}/{{ data["build__checkout__git_repository_branch"] }} | ||
| - Origin: {{ data["test_origin"] }} | ||
| - Test_lab: {{ data["runtime"] }} | ||
| - Commit: {{ data["build__checkout__git_commit_hash"] }} | ||
| - Dashboard: https://dashboard.kernelci.org/test/{{ data["id"] }} | ||
| ------------- | ||
| {% endfor %} | ||
| {%- endblock -%} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should the user be able to change this value? 30 days seem to be long, maybe the user just wants a recent summary