From 03a002339847cbbd368b0d4535e54d2df47ebb98 Mon Sep 17 00:00:00 2001 From: Alex Kennedy Date: Sun, 19 Jan 2025 16:01:22 -0800 Subject: [PATCH 1/5] fix: Reporting schema issues and trailing whitespace --- assets/css/taskcat_reporting.css | 5 +- taskcat/_cfn/_log_stack_events.py | 17 ++-- taskcat/_generate_reports.py | 128 ++++++++++++++---------------- 3 files changed, 70 insertions(+), 80 deletions(-) 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..b6e2b288a 100644 --- a/taskcat/_generate_reports.py +++ b/taskcat/_generate_reports.py @@ -35,19 +35,16 @@ def get_output_file(region, stack_name, resource_type): if resource_type == "cfnlog": location = f"{stack_name}-{region}-cfnlogs{extension}" return str(location) - if resource_type == "resource_log": + elif resource_type == "resource_log": location = f"{stack_name}-{region}-resources{extension}" return str(location) return None def get_teststate(stack: Stack): rstatus = stack.status + status_css = "class=test-red" 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 @@ -61,40 +58,37 @@ def get_teststate(stack: Stack): output_css = requests.get(css_url, timeout=3).text doc_link = "http://taskcat.io" + # Render + 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) + tag("meta", charset="utf-8") + tag("meta", name="viewport", content="width=device-width") + with tag("style"): + text("\n" + output_css) with tag("title"): text("TaskCat Report") - with tag("body"): tested_on = time.strftime("%A - %b,%d,%Y @ %H:%M:%S") - with tag("table", "class=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("td"): + with tag("a", href=repo_link): + text("GitHub Repo: ") + text(repo_link) + tag("br") + with tag("a", href=doc_link): + text("Documentation: ") + text(doc_link) + tag("br") + text("Tested on: ") + text(tested_on) + with tag("td", "class=taskcat-logo"): + with tag("h3"): + text(logo) + with tag("table", "class=table-fill"): + with tag("thead"): with tag("tr"): with tag("th", "class=text-center", "width=25%"): text("Test Name") @@ -106,42 +100,38 @@ def get_teststate(stack: Stack): 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("tbody"): + for stack in self._stacks.stacks: + 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(stack.test_name) + 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 + text("") + with tag("tfoot"): + with tag("tr", "class=test-footer"): + with tag("td", "colspan=5"): + text(f"Generated by taskcat {self._version}") + + # 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 From a33532c0d1d830d9aad42aacc96faa6553f33436 Mon Sep 17 00:00:00 2001 From: Alex Kennedy Date: Sun, 19 Jan 2025 16:41:48 -0800 Subject: [PATCH 2/5] fix: black --- taskcat/_generate_reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taskcat/_generate_reports.py b/taskcat/_generate_reports.py index b6e2b288a..f6f7bb8f9 100644 --- a/taskcat/_generate_reports.py +++ b/taskcat/_generate_reports.py @@ -59,7 +59,7 @@ def get_teststate(stack: Stack): doc_link = "http://taskcat.io" # Render - doc.asis('') + doc.asis("") with tag("html"): with tag("head"): tag("meta", charset="utf-8") From b0c9c2277ae626ef9976adc3999c86a2eb94d04a Mon Sep 17 00:00:00 2001 From: Alex Kennedy Date: Sun, 19 Jan 2025 16:57:13 -0800 Subject: [PATCH 3/5] revert: Partial elif after return --- taskcat/_generate_reports.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/taskcat/_generate_reports.py b/taskcat/_generate_reports.py index f6f7bb8f9..86b3662b3 100644 --- a/taskcat/_generate_reports.py +++ b/taskcat/_generate_reports.py @@ -32,12 +32,12 @@ def generate_report( # noqa: C901 # Type of cfn log return cfn log file def get_output_file(region, stack_name, resource_type): extension = ".txt" + if resource_type == "resource_log": + location = f"{stack_name}-{region}-resources{extension}" + return str(location) if resource_type == "cfnlog": location = f"{stack_name}-{region}-cfnlogs{extension}" return str(location) - elif resource_type == "resource_log": - location = f"{stack_name}-{region}-resources{extension}" - return str(location) return None def get_teststate(stack: Stack): From 3737e5d6fde2abfe9cff65d2e276877313d746e4 Mon Sep 17 00:00:00 2001 From: Alex Kennedy Date: Sun, 19 Jan 2025 16:57:45 -0800 Subject: [PATCH 4/5] revert: Partial if ordering --- taskcat/_generate_reports.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/taskcat/_generate_reports.py b/taskcat/_generate_reports.py index 86b3662b3..ef0a4d859 100644 --- a/taskcat/_generate_reports.py +++ b/taskcat/_generate_reports.py @@ -32,12 +32,12 @@ def generate_report( # noqa: C901 # Type of cfn log return cfn log file def get_output_file(region, stack_name, resource_type): extension = ".txt" - if resource_type == "resource_log": - location = f"{stack_name}-{region}-resources{extension}" - return str(location) 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): From 8ff3b3d49428a30f500aca41636a94bfe013d180 Mon Sep 17 00:00:00 2001 From: Alex Kennedy Date: Mon, 20 Jan 2025 01:01:31 -0800 Subject: [PATCH 5/5] checkpoint --- taskcat/_generate_reports.py | 203 +++++++++++++++++-------------- taskcat/testing/_cfn_test.py | 7 +- tests/testing_module/test_cfn.py | 7 +- 3 files changed, 125 insertions(+), 92 deletions(-) diff --git a/taskcat/_generate_reports.py b/taskcat/_generate_reports.py index ef0a4d859..e23bc3147 100644 --- a/taskcat/_generate_reports.py +++ b/taskcat/_generate_reports.py @@ -12,121 +12,144 @@ 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 - status_css = "class=test-red" - if rstatus == "CREATE_COMPLETE": - status_css = "class=test-green" - 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 - # Render doc.asis("") with tag("html"): with tag("head"): - tag("meta", charset="utf-8") - tag("meta", name="viewport", content="width=device-width") + stag("meta", charset="utf-8") + stag("meta", name="viewport", content="width=device-width") with tag("style"): - text("\n" + output_css) - with tag("title"): - text("TaskCat Report") + 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("tr"): with tag("td"): - with tag("a", href=repo_link): - text("GitHub Repo: ") - text(repo_link) - tag("br") - with tag("a", href=doc_link): - text("Documentation: ") - text(doc_link) - tag("br") - text("Tested on: ") - text(tested_on) - with tag("td", "class=taskcat-logo"): - with tag("h3"): - text(logo) - with tag("table", "class=table-fill"): + 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"): - 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") + 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)}") - status, css = get_teststate(stack) with tag("tr"): - with tag("td", "class=test-info"): - with tag("h3"): - text(stack.test_name) - 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( + 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" ) - with tag("a", href=clog): - text("View Logs") - with tag("tr", "class=test-footer"): - with tag("td", "colspan=5"): - text("") + line("a", "View Logs", href=log_file) + with tag("tr", klass="test-footer"): + line("td", " ", colspan=5) + # Footer with tag("tfoot"): - with tag("tr", "class=test-footer"): - with tag("td", "colspan=5"): - text(f"Generated by taskcat {self._version}") + 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( 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()