diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 66a6c7c86..7da1d216d 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -15,6 +15,7 @@ import copy import os import json +import pathlib import re import time import shutil @@ -234,6 +235,8 @@ def _write_reports(self, test_report): util.run_command(f"chown -R {self._host_user} {out_dir}") + self._cleanup_modules_html_reports(out_dir) + def _generate_report(self): device = self.get_session().get_target_device() @@ -271,6 +274,27 @@ def _generate_report(self): return report + def _cleanup_modules_html_reports(self, out_dir): + """Cleans up any HTML reports generated by test modules to save space.""" + + for module in self._test_modules: + module_template_path = os.path.join( + os.path.join(out_dir, module.name), + f"{module.name}_report.j2.html") + module_report_path = os.path.join( + os.path.join(out_dir, module.name), + f"{module.name}_report.jinja2") + try: + if os.path.exists(module_template_path): + os.remove(module_template_path) + LOGGER.debug(f"Removed module template: {module_template_path}") + if os.path.exists(module_report_path): + p = pathlib.Path(module_report_path) + p.rename(p.with_suffix(".html")) + except Exception as e: + LOGGER.error(f"Error {module_template_path}: {e}") + + def _cleanup_old_test_results(self, device): if device.max_device_reports is not None: diff --git a/modules/test/base/base.Dockerfile b/modules/test/base/base.Dockerfile index 7a82301a7..6a13b42e0 100644 --- a/modules/test/base/base.Dockerfile +++ b/modules/test/base/base.Dockerfile @@ -84,9 +84,29 @@ ENV PATH="/opt/venv/bin:$PATH" ENV REPORT_TEMPLATE_PATH=/testrun/resources # Jinja base template ENV BASE_TEMPLATE_FILE=module_report_base.jinja2 +# Jinja preview template +ENV BASE_TEMPLATE_PREVIEW_FILE=module_report_base_preview.jinja2 +# Jinja base template +ENV BASE_TEMPLATE_STYLED_FILE=module_report_styled.jinja2 +# Styles +ENV CSS_FILE=test_report_styles.css +# ICON +ENV LOGO_FILE=testrun.png # Copy base template COPY resources/report/$BASE_TEMPLATE_FILE $REPORT_TEMPLATE_PATH/ +# Copy base preview template +COPY resources/report/$BASE_TEMPLATE_PREVIEW_FILE $REPORT_TEMPLATE_PATH/ + +# Copy base template (with styles) +COPY resources/report/$BASE_TEMPLATE_STYLED_FILE $REPORT_TEMPLATE_PATH/ + +# Copy styles +COPY resources/report/$CSS_FILE $REPORT_TEMPLATE_PATH/ + +# Copy icon +COPY resources/report/$LOGO_FILE $REPORT_TEMPLATE_PATH/ + # Start the test module ENTRYPOINT [ "/testrun/bin/start" ] \ No newline at end of file diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index 8030d7757..7e376fa64 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. """Base class for all core test module functions""" + +import base64 import json import logger import os import util from datetime import datetime import traceback +from jinja2 import Environment, FileSystemLoader, BaseLoader from common.statuses import TestResult @@ -44,6 +47,12 @@ def __init__(self, self._device_test_pack = json.loads(os.environ.get('DEVICE_TEST_PACK', '')) self._report_template_folder = os.environ.get('REPORT_TEMPLATE_PATH') self._base_template_file=os.environ.get('BASE_TEMPLATE_FILE') + self._base_template_file_preview=os.environ.get( + 'BASE_TEMPLATE_PREVIEW_FILE' + ) + self._base_template_styled_file=os.environ.get('BASE_TEMPLATE_STYLED_FILE') + self._css_file=os.environ.get('CSS_FILE') + self._logo_file=os.environ.get('LOGO_FILE') self._log_level = os.environ.get('LOG_LEVEL', None) self._add_logger(log_name=log_name) self._config = self._read_config( @@ -229,3 +238,38 @@ def _get_device_ipv4(self): if text: return text.split('\n')[0] return None + + def _render_styled_report(self, jinja_report, result_path): + # Report styles + with open(os.path.join(self._report_template_folder, + self._css_file), + 'r', + encoding='UTF-8' + ) as style_file: + styles = style_file.read() + + # Load Testrun logo to base64 + with open(os.path.join(self._report_template_folder, + self._logo_file), 'rb') as f: + logo = base64.b64encode(f.read()).decode('utf-8') + + loader=FileSystemLoader(self._report_template_folder) + template = Environment( + loader=loader, + trim_blocks=True, + lstrip_blocks=True + ).get_template(self._base_template_styled_file) + + module_template = Environment(loader=BaseLoader() + ).from_string(jinja_report).render( + title = 'Testrun report', + logo=logo, + ) + + report_jinja_styled = template.render( + template=module_template, + styles=styles + ) + # Write the styled content to a file + with open(result_path, 'w', encoding='utf-8') as file: + file.write(report_jinja_styled) diff --git a/modules/test/dns/python/src/dns_module.py b/modules/test/dns/python/src/dns_module.py index 5c23919f1..0756d32a5 100644 --- a/modules/test/dns/python/src/dns_module.py +++ b/modules/test/dns/python/src/dns_module.py @@ -22,6 +22,7 @@ LOG_NAME = 'test_dns' MODULE_REPORT_FILE_NAME = 'dns_report.j2.html' +MODULE_REPORT_STYLED_FILE_NAME = 'dns_report.jinja2' DNS_SERVER_CAPTURE_FILE = '/runtime/network/dns.pcap' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' @@ -161,6 +162,7 @@ def generate_module_report(self): pages_content.append(current_page_rows) report_html = '' + report_jinja_preview = '' if not pages_content: pages_content = [[]] @@ -175,9 +177,23 @@ def generate_module_report(self): module_data=page_rows ) report_html += page_html + page_html = template.render( + base_template=self._base_template_file_preview, + module_header=module_header_repr, + summary_headers=summary_headers, + summary_data=summary_data, + module_data_headers=module_data_headers, + module_data=page_rows + ) + report_jinja_preview += page_html LOGGER.debug('Module report:\n' + report_html) + # Generate styled report for a preview + jinja_path_styled = os.path.join( + self._results_dir, MODULE_REPORT_STYLED_FILE_NAME) + self._render_styled_report(report_jinja_preview, jinja_path_styled) + # Use os.path.join to create the complete file path report_path = os.path.join(self._results_dir, MODULE_REPORT_FILE_NAME) @@ -219,7 +235,6 @@ def extract_dns_data(self): qname = dns_layer.qd.qname.decode() if dns_layer.qd.qname else 'N/A' else: qname = 'N/A' - resolved_ip = 'N/A' # If it's a response packet, extract the resolved IP address # from the answer section @@ -237,14 +252,15 @@ def extract_dns_data(self): elif answer.type == 28: # Indicates AAAA record (IPv6 address) resolved_ip = answer.rdata # Extract IPv6 address break # Stop after finding the first valid resolved IP - + qname = qname.rstrip('.') if (isinstance(qname, str) + and qname.endswith('.')) else qname dns_data.append({ 'Timestamp': float(packet.time), # Timestamp of the DNS packet 'Source': source_ip, 'Destination': destination_ip, 'ResolvedIP': resolved_ip, # Adding the resolved IP address 'Type': dns_type, - 'Data': qname[:-1] + 'Data': qname, }) # Filter unique entries based on 'Timestamp' diff --git a/modules/test/ntp/python/src/ntp_module.py b/modules/test/ntp/python/src/ntp_module.py index 76c3aeb6a..91a5f9376 100644 --- a/modules/test/ntp/python/src/ntp_module.py +++ b/modules/test/ntp/python/src/ntp_module.py @@ -23,6 +23,7 @@ LOG_NAME = 'test_ntp' MODULE_REPORT_FILE_NAME = 'ntp_report.j2.html' +MODULE_REPORT_STYLED_FILE_NAME = 'ntp_report.jinja2' NTP_SERVER_CAPTURE_FILE = '/runtime/network/ntp.pcap' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' @@ -142,7 +143,7 @@ def generate_module_report(self): # Generate the HTML table with the count column for (src, dst, typ, - version), avg_diff in average_time_between_requests.items(): + version), avg_diff in average_time_between_requests.items(): cnt = len(timestamps[(src, dst, typ, version)]) # Sync Average only applies to client requests @@ -169,16 +170,28 @@ def generate_module_report(self): rows_on_page = ((page_useful_space) // row_height) - 1 start = 0 report_html = '' + report_html_styled = '' for page in range(pages + 1): end = start + min(len(module_table_data), rows_on_page) module_header_repr = module_header if page == 0 else None - page_html = template.render(base_template=self._base_template_file, - module_header=module_header_repr, - summary_headers=summary_headers, - summary_data=summary_data, - module_data_headers=module_data_headers, - module_data=module_table_data[start:end]) + page_html = template.render( + base_template=self._base_template_file, + module_header=module_header_repr, + summary_headers=summary_headers, + summary_data=summary_data, + module_data_headers=module_data_headers, + module_data=module_table_data[start:end] + ) + page_html_styled = template.render( + base_template=self._base_template_file_preview, + module_header=module_header_repr, + summary_headers=summary_headers, + summary_data=summary_data, + module_data_headers=module_data_headers, + module_data=module_table_data[start:end] + ) report_html += page_html + report_html_styled += page_html_styled start = end LOGGER.debug('Module report:\n' + report_html) @@ -186,6 +199,13 @@ def generate_module_report(self): # Use os.path.join to create the complete file path report_path = os.path.join(self._results_dir, MODULE_REPORT_FILE_NAME) + # Use os.path.join to create the complete file path for styled report + report_path_styled = os.path.join( + self._results_dir, MODULE_REPORT_STYLED_FILE_NAME + ) + # Generate the styled report for preview + self._render_styled_report(report_html_styled, report_path_styled) + # Write the content to a file with open(report_path, 'w', encoding='utf-8') as file: file.write(report_html) diff --git a/modules/test/ntp/python/src/ntp_white_list.py b/modules/test/ntp/python/src/ntp_white_list.py index eafd072f0..51269a235 100644 --- a/modules/test/ntp/python/src/ntp_white_list.py +++ b/modules/test/ntp/python/src/ntp_white_list.py @@ -1,6 +1,7 @@ """Module to resolve NTP whitelist domains to IP addresses asynchronously.""" import asyncio +import concurrent.futures import dns.asyncresolver from logging import Logger @@ -99,8 +100,18 @@ def _get_ntp_whitelist_ips( semaphore_limit: int = 50, timeout: int = 30 ) -> set[str]: - return asyncio.run( - self._get_ips_whitelist(self.config, semaphore_limit, timeout)) + # Always run in a separate thread to ensure we have a clean event loop + def run_in_thread(): + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + try: + return new_loop.run_until_complete( + self._get_ips_whitelist(self.config, semaphore_limit, timeout)) + finally: + new_loop.close() + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(run_in_thread).result() # Check if an IP is whitelisted def is_ip_whitelisted(self, ip: str) -> bool: diff --git a/modules/test/services/python/src/services_module.py b/modules/test/services/python/src/services_module.py index c454f4f93..ea925b6e9 100644 --- a/modules/test/services/python/src/services_module.py +++ b/modules/test/services/python/src/services_module.py @@ -23,6 +23,7 @@ LOG_NAME = 'test_services' MODULE_REPORT_FILE_NAME = 'services_report.j2.html' +MODULE_REPORT_STYLED_FILE_NAME = 'services_report.jinja2' NMAP_SCAN_RESULTS_SCAN_FILE = 'services_scan_results.json' LOGGER = None REPORT_TEMPLATE_FILE = 'report_template.jinja2' @@ -129,9 +130,22 @@ def generate_module_report(self): module_data_headers=module_data_headers, module_data=module_data, ) + html_content_preview = template.render( + base_template=self._base_template_file_preview, + module_header=module_header, + summary_headers=summary_headers, + summary_data=summary_data, + module_data_headers=module_data_headers, + module_data=module_data, + ) LOGGER.debug('Module report:\n' + html_content) + # Generate styled report for a preview + jinja_path_styled = os.path.join( + self._results_dir, MODULE_REPORT_STYLED_FILE_NAME) + self._render_styled_report(html_content_preview, jinja_path_styled) + # Use os.path.join to create the complete file path report_path = os.path.join(self._results_dir, MODULE_REPORT_FILE_NAME) diff --git a/modules/test/tls/python/src/tls_module.py b/modules/test/tls/python/src/tls_module.py index cc71bac12..f20e4686c 100644 --- a/modules/test/tls/python/src/tls_module.py +++ b/modules/test/tls/python/src/tls_module.py @@ -31,6 +31,7 @@ LOG_NAME = 'test_tls' MODULE_REPORT_FILE_NAME = 'tls_report.j2.html' +MODULE_REPORT_STYLED_FILE_NAME = 'tls_report.jinja2' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' TLS_CAPTURE_FILE = '/runtime/output/tls.pcap' @@ -93,12 +94,14 @@ def generate_module_report(self): self.tls_capture_file ] certificates = self.extract_certificates_from_pcap(pcap_files, - self._device_mac) + self._device_mac) + + if len(certificates) > 0: # pylint: disable=W0612 for cert_num, ((ip_address, port), - cert) in enumerate(certificates.items()): + cert) in enumerate(certificates.items()): pages[cert_num] = {} # Extract certificate data @@ -168,28 +171,45 @@ def generate_module_report(self): ] report_jinja = '' + report_jinja_preview = '' if pages: for num,page in pages.items(): module_header_repr = module_header if num == 0 else None cert_ext=page['cert_ext'] if 'cert_ext' in page else None page_html = template.render( - base_template=self._base_template_file, - module_header=module_header_repr, - summary_headers=summary_headers, - summary_data=page['summary_data'], - cert_info_data=page['cert_info_data'], - subject_data=page['subject_data'], - cert_table_headers=cert_table_headers, - cert_ext=cert_ext, - ountbound_headers=outbound_headers, - ) + base_template=self._base_template_file, + module_header=module_header_repr, + summary_headers=summary_headers, + summary_data=page['summary_data'], + cert_info_data=page['cert_info_data'], + subject_data=page['subject_data'], + cert_table_headers=cert_table_headers, + cert_ext=cert_ext, + ountbound_headers=outbound_headers, + ) report_jinja += page_html + page_html = template.render( + base_template=self._base_template_file_preview, + module_header=module_header_repr, + summary_headers=summary_headers, + summary_data=page['summary_data'], + cert_info_data=page['cert_info_data'], + subject_data=page['subject_data'], + cert_table_headers=cert_table_headers, + cert_ext=cert_ext, + ountbound_headers=outbound_headers, + ) + report_jinja_preview += page_html else: report_jinja = template.render( - base_template=self._base_template_file, - module_header = module_header, - ) + base_template=self._base_template_file, + module_header = module_header, + ) + report_jinja_preview = template.render( + base_template=self._base_template_file_preview, + module_header = module_header, + ) outbound_conns = self._tls_util.get_all_outbound_connections( device_mac=self._device_mac, capture_files=pcap_files) @@ -209,10 +229,22 @@ def generate_module_report(self): ountbound_headers=outbound_headers, outbound_conns=outbound_conns_chunk ) + out_page_preview = template.render( + base_template=self._base_template_file_preview, + ountbound_headers=outbound_headers, + outbound_conns=outbound_conns_chunk + ) + report_jinja += out_page + report_jinja_preview += out_page_preview LOGGER.debug('Module report:\n' + report_jinja) + # Generate styled report for a preview + jinja_path_styled = os.path.join( + self._results_dir, MODULE_REPORT_STYLED_FILE_NAME) + self._render_styled_report(report_jinja_preview, jinja_path_styled) + # Use os.path.join to create the complete file path jinja_path = os.path.join(self._results_dir, MODULE_REPORT_FILE_NAME) @@ -221,6 +253,7 @@ def generate_module_report(self): file.write(report_jinja) LOGGER.info('Module report generated at: ' + str(jinja_path)) + return jinja_path def format_extension_value(self, value): diff --git a/resources/report/module_report_base_preview.jinja2 b/resources/report/module_report_base_preview.jinja2 new file mode 100644 index 000000000..ffec8fc88 --- /dev/null +++ b/resources/report/module_report_base_preview.jinja2 @@ -0,0 +1,41 @@ +{% raw %} +
+
+ Testrun +
+{% endraw %} +
+ {% if module_header %} +

{{ module_header }}

+ {% if summary_headers %} + + {% endif %} + {% elif summary_headers %} +
+ {% endif %} + {% if summary_headers %} + + + {% for header in summary_headers %} + + {% endfor %} + + + + + {% for cell in summary_data %} + + {% endfor %} + + +
{{ header }}
{{ cell }}
+ {% endif %} + {% block content %}{% endblock content %} +
+{% raw %} + +
+
+{% endraw %} \ No newline at end of file diff --git a/resources/report/module_report_styled.jinja2 b/resources/report/module_report_styled.jinja2 new file mode 100644 index 000000000..fb39692ac --- /dev/null +++ b/resources/report/module_report_styled.jinja2 @@ -0,0 +1,12 @@ + + + + + + Testrun Report + + + + {{ template }} + + \ No newline at end of file diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index dfcfb9816..d6ebf2552 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -555,6 +555,8 @@ background-repeat: no-repeat !important; background-size: 14px; background-position: center; + padding: 0; + margin: 0; } .result-test-required-result {