diff --git a/assets/css/taskcat_reporting.css b/assets/css/taskcat_reporting.css index e00d2214a..a38646565 100644 --- a/assets/css/taskcat_reporting.css +++ b/assets/css/taskcat_reporting.css @@ -3,7 +3,8 @@ @import url(https://fonts.googleapis.com/css?family=Roboto:400,500,700,300,100); @import url(https://fonts.googleapis.com/css?family=Comfortaa:700); -html {} body { + +body { background-color: #ecf0f1; font-family: "Roboto", helvetica, arial, sans-serif; font-size: 12px; @@ -153,7 +154,7 @@ tr:last-child td:last-child { } td { background: #ffffff; - padding: 12; + padding: 12px; text-align: left; vertical-align: middle; font-weight: 300; diff --git a/taskcat/_cfn/_log_stack_events.py b/taskcat/_cfn/_log_stack_events.py index 7ec2cbfc8..aac145666 100644 --- a/taskcat/_cfn/_log_stack_events.py +++ b/taskcat/_cfn/_log_stack_events.py @@ -64,13 +64,12 @@ def write_logs(self, stack: Stack, logpath: Path): return if len(cfnlogs) != 0: - if cfnlogs[0]["ResourceStatus"] != "CREATE_COMPLETE": - if "ResourceStatusReason" in cfnlogs[0]: - reason = cfnlogs[0]["ResourceStatusReason"] - else: - reason = "Unknown" - else: + if cfnlogs[0]["ResourceStatus"] == "CREATE_COMPLETE": reason = "Stack launch was successful" + elif "ResourceStatusReason" in cfnlogs[0]: + reason = cfnlogs[0]["ResourceStatusReason"] + else: + reason = "Unknown" with open(str(logpath), "a", encoding="utf-8") as log_output: log_output.write( @@ -83,7 +82,7 @@ def write_logs(self, stack: Stack, logpath: Path): "******************************************************************" "***********\n" ) - log_output.write("ResourceStatusReason: \n") + log_output.write("ResourceStatusReason:") log_output.write(textwrap.fill(str(reason), 85) + "\n") log_output.write( "******************************************************************" @@ -93,7 +92,7 @@ def write_logs(self, stack: Stack, logpath: Path): "******************************************************************" "***********\n" ) - log_output.write("Events: \n") + log_output.write("Events:\n") log_output.writelines(tabulate.tabulate(cfnlogs, headers="keys")) log_output.write( "\n****************************************************************" @@ -118,5 +117,5 @@ def write_logs(self, stack: Stack, logpath: Path): self.write_logs(child, logpath) else: LOG.error( - "No event logs found. Something went wrong at describe event " "call." + "No event logs found. Something went wrong at describe event call." ) diff --git a/taskcat/_generate_reports.py b/taskcat/_generate_reports.py index ca136b7c8..e23bc3147 100644 --- a/taskcat/_generate_reports.py +++ b/taskcat/_generate_reports.py @@ -12,136 +12,149 @@ LOG = logging.getLogger(__name__) +REPO_LINK = "https://github.com/aws-ia/taskcat" +DOC_LINK = "http://taskcat.io" + + class ReportBuilder: - """ - This class generates the test report. + """This class generates the test report.""" - """ + STYLE_SHEET_URL = "https://raw.githubusercontent.com/aws-ia/taskcat/main/assets/css/taskcat_reporting.css" - def __init__(self, stacks: Stacker, output_file: Path, version: str = "0.9.0"): + def __init__(self, stacks: Stacker, output_file: Path, version: str): self._stacks = stacks self._output_file = output_file self._version = version - # TODO: refactor for readability - def generate_report( # noqa: C901 - self, - ): # pylint: disable=too-many-locals, too-many-statements - doc = yattag.Doc() - - # Type of cfn log return cfn log file - def get_output_file(region, stack_name, resource_type): - extension = ".txt" - if resource_type == "cfnlog": - location = f"{stack_name}-{region}-cfnlogs{extension}" - return str(location) - if resource_type == "resource_log": - location = f"{stack_name}-{region}-resources{extension}" - return str(location) - return None - - def get_teststate(stack: Stack): - rstatus = stack.status - if rstatus == "CREATE_COMPLETE": - status_css = "class=test-green" - elif rstatus == "CREATE_FAILED": - status_css = "class=test-red" - else: - status_css = "class=test-red" - return rstatus, status_css - - tag = doc.tag - text = doc.text - logo = "taskcat" - repo_link = "https://github.com/aws-ia/taskcat" - css_url = ( - "https://raw.githubusercontent.com/aws-ia/taskcat/main/" - "assets/css/taskcat_reporting.css" - ) - output_css = requests.get(css_url, timeout=3).text - doc_link = "http://taskcat.io" + def _get_output_file(self, region, stack_name, resource_type): + """Determine the output file name to use.""" + + extension = ".txt" + if resource_type == "cfnlog": + location = f"{stack_name}-{region}-cfnlogs{extension}" + return str(location) + if resource_type == "resource_log": + location = f"{stack_name}-{region}-resources{extension}" + return str(location) + return None + + def _get_stack_status_badge(self, stack: Stack): + """Generate a status and associated class from the stack status.""" + + status = stack.status + status_class = "test-red" + status_icon = "❌" + if status == "CREATE_COMPLETE": + status_icon = "✅" + status_class = "test-green" + return f"{status_icon} {status}", status_class + + def _get_css(self): + """Gets the latest CSS for the report from this github repo when building the report.""" + + stylesheet = """th, td { + border: 1px solid black; + table { + border-collapse: collapse; + } + td.test-green { + background-color: #98FF98; + } + td.test-red { + background-color: #FCB3BC; + } + """ + r = requests.get(url=self.__class__.STYLE_SHEET_URL, timeout=3) + try: + r.raise_for_status() + stylesheet = r.text + except requests.exceptions.HTTPError as e: + LOG.error( + f"Failed to fetch stylesheet, falling back to default stylesheet: {e}" + ) + return stylesheet + + def _render_document(self, report_timestamp): + """Renders the report document HTML.""" + + doc, tag, text = yattag.SimpleDoc(stag_end=">").tagtext() + stag = doc.stag + line = doc.line + + doc.asis("") with tag("html"): with tag("head"): - doc.stag("meta", charset="utf-8") - doc.stag("meta", name="viewport", content="width=device-width") - with tag("style", type="text/css"): - text(output_css) - with tag("title"): - text("TaskCat Report") - + stag("meta", charset="utf-8") + stag("meta", name="viewport", content="width=device-width") + with tag("style"): + text("\n" + self._get_css()) + line("title", "TaskCat Report") with tag("body"): - tested_on = time.strftime("%A - %b,%d,%Y @ %H:%M:%S") - - with tag("table", "class=header-table-fill"): + # Header / Banner + with tag("table", klass="header-table-fill"): with tag("tbody"): - with tag("th", "colspan=2"): - with tag("tr"): - with tag("td"): - with tag("a", href=repo_link): - text("GitHub Repo: ") - text(repo_link) - doc.stag("br") - with tag("a", href=doc_link): - text("Documentation: ") - text(doc_link) - doc.stag("br") - text("Tested on: ") - text(tested_on) - with tag("td", "class=taskcat-logo"): - with tag("h3"): - text(logo) - doc.stag("p") - with tag("table", "class=table-fill"): - with tag("tbody"): - with tag("thread"): with tag("tr"): - with tag("th", "class=text-center", "width=25%"): - text("Test Name") - with tag("th", "class=text-left", "width=10%"): - text("Tested Region") - with tag("th", "class=text-left", "width=30%"): - text("Stack Name") - with tag("th", "class=text-left", "width=20%"): - text("Tested Results") - with tag("th", "class=text-left", "width=15%"): - text("Test Logs") - - for stack in self._stacks.stacks: - with tag("tr", "class= test-footer"): - with tag("td", "colspan=5"): - text("") - - testname = stack.test_name - LOG.info(f"Reporting on {str(stack.id)}") - status, css = get_teststate(stack) - - with tag("tr"): - with tag("td", "class=test-info"): - with tag("h3"): - text(testname) - with tag("td", "class=text-left"): - text(stack.region_name) - with tag("td", "class=text-left"): - text(stack.name) - with tag("td", css): - text(str(status)) - with tag("td", "class=text-left"): - clog = get_output_file( - stack.region_name, stack.name, "cfnlog" - ) - with tag("a", href=clog): - text("View Logs ") - with tag("tr", "class= test-footer"): - with tag("td", "colspan=5"): - vtag = "Generated by taskcat {self._version}" - text(vtag) - - doc.stag("p") - - html_output = yattag.indent( - doc.getvalue(), indentation=" ", newline="\r\n", indent_text=True - ) - with open(str(self._output_file.resolve()), "w", encoding="utf-8") as _f: - _f.write(html_output) - return html_output + with tag("td"): + line("a", f"GitHub Repo: {REPO_LINK}", href=REPO_LINK) + stag("br") + line("a", f"Documentation: {DOC_LINK}", href=DOC_LINK) + stag("br") + text(f"Report Generated: {report_timestamp}") + with tag("td", klass="taskcat-logo"): + line("h3", "taskcat") + with tag("table", klass="table-fill"): + with tag("thead"): + with tag("tr"): + line("th", "Test Name", klass="text-center", width="25%") + line("th", "Tested Region", klass="text-left", width="10%") + line("th", "Stack Name", klass="text-left", width="30%") + line("th", "Tested Results", klass="text-left", width="20%") + line("th", "Test Logs", klass="text-left", width="15%") + # Test Results + with tag("tbody"): + for stack in self._stacks.stacks: + stack: Stack # type hint + LOG.info(f"Reporting on {str(stack.id)}") + with tag("tr"): + with tag("td", klass="test-info"): + line("h3", stack.test_name) + line("td", stack.region_name, klass="text-left") + line("td", stack.name, klass="text-left") + status, status_class = self._get_stack_status_badge( + stack + ) + line("td", status, klass=status_class) + with tag("td", klass="text-left"): + log_file = self._get_output_file( + stack.region_name, stack.name, "cfnlog" + ) + line("a", "View Logs", href=log_file) + with tag("tr", klass="test-footer"): + line("td", " ", colspan=5) + # Footer + with tag("tfoot"): + with tag("tr", klass="test-footer"): + line( + "td", f"Generated by taskcat {self._version}", colspan=5 + ) + + return doc + + def generate_report( + self, + ): + """Generate the report.""" + + report_timestamp = time.strftime("%A - %b,%d,%Y @ %H:%M:%S") + + # Render + doc = self._render_document(report_timestamp) + + # Output + html_output = yattag.indent( + doc.getvalue(), indentation=" ", newline="\r\n", indent_text=True + ) + with open(str(self._output_file.resolve()), "w", encoding="utf-8") as _f: + _f.write(html_output) + return html_output diff --git a/taskcat/testing/_cfn_test.py b/taskcat/testing/_cfn_test.py index 181c6eed1..450fb4dfc 100644 --- a/taskcat/testing/_cfn_test.py +++ b/taskcat/testing/_cfn_test.py @@ -1,4 +1,5 @@ # pylint: disable=line-too-long +import importlib.metadata import logging from pathlib import Path from typing import List as ListType, Union @@ -197,10 +198,14 @@ def report( cfn_logs = _CfnLogTools() cfn_logs.createcfnlogs(self.test_definition, report_path) ReportBuilder( - self.test_definition, report_path / "index.html" + self.test_definition, report_path / "index.html", get_installed_version() ).generate_report() +def get_installed_version(): + return importlib.metadata.version(__package__ or __name__) + + def _trim_regions(regions, config): if regions != "ALL": for test in config.config.tests.values(): diff --git a/tests/testing_module/test_cfn.py b/tests/testing_module/test_cfn.py index d7d02d584..f258c5162 100644 --- a/tests/testing_module/test_cfn.py +++ b/tests/testing_module/test_cfn.py @@ -253,10 +253,15 @@ def test_end_keep_failed(self, mock_config: mm): self.assertTrue("One or more stacks failed to create:" in str(ex.exception)) + @patch("taskcat.testing._cfn_test.get_installed_version") @patch("taskcat.testing._cfn_test.Config") @patch("taskcat.testing._cfn_test._CfnLogTools") @patch("taskcat.testing._cfn_test.ReportBuilder") - def test_report(self, mock_report: mm, mock_log: mm, mock_config: mm): + def test_report( + self, mock_report: mm, mock_log: mm, mock_config: mm, mock_version: mm + ): + mock_version.return_value = "1.0.0" + cfn_test = CFNTest(mock_config()) td_mock = MagicMock()