diff --git a/requirements.txt b/requirements.txt index 165f962..2e164e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,6 +46,7 @@ setuptools==70.3.0 ; python_version >= "3.9" and python_full_version < "3.13" shellingham==1.5.4 ; python_version >= "3.9" and python_full_version < "3.13" six==1.16.0 ; python_version >= "3.9" and python_full_version < "3.13" slack-sdk==3.27.1 ; python_version >= "3.9" and python_full_version < "3.13" +tabulate==0.9.0 ; python_version >= "3.9" and python_full_version < "3.13" typer[all]==0.7.0 ; python_version >= "3.9" and python_full_version < "3.13" typing-extensions==4.6.0 ; python_version >= "3.9" and python_full_version < "3.13" tzdata==2024.1 ; python_version >= "3.9" and python_full_version < "3.13" diff --git a/robusta_krr/core/models/severity.py b/robusta_krr/core/models/severity.py index 7d9ec13..8812be5 100644 --- a/robusta_krr/core/models/severity.py +++ b/robusta_krr/core/models/severity.py @@ -30,6 +30,16 @@ def color(self) -> str: self.CRITICAL: "red", }[self] + @property + def emoji(self) -> str: + return { + self.UNKNOWN: "❔", + self.GOOD: "✅", + self.OK: "🟩", + self.WARNING: "🟨", + self.CRITICAL: "🟥", + }[self] + @classmethod def calculate( cls, current: RecommendationValue, recommended: RecommendationValue, resource_type: ResourceType diff --git a/robusta_krr/core/runner.py b/robusta_krr/core/runner.py index 9a8b1a8..75a3603 100644 --- a/robusta_krr/core/runner.py +++ b/robusta_krr/core/runner.py @@ -120,8 +120,8 @@ def _process_result(self, result: Result) -> None: file_name = settings.slack_output with open(file_name, "w") as target_file: - # don't use rich when writing a csv or html to avoid line wrapping etc - if settings.format == "csv" or settings.format == "html": + # don't use rich when writing a csv, html or markdown to avoid line wrapping etc + if settings.format == "csv" or settings.format == "html" or settings.format == "markdown": target_file.write(formatted) else: console = Console(file=target_file, width=settings.width) diff --git a/robusta_krr/formatters/__init__.py b/robusta_krr/formatters/__init__.py index 7e1d164..e127ad1 100644 --- a/robusta_krr/formatters/__init__.py +++ b/robusta_krr/formatters/__init__.py @@ -4,3 +4,4 @@ from .yaml import yaml from .csv import csv from .html import html +from .markdown import markdown diff --git a/robusta_krr/formatters/markdown.py b/robusta_krr/formatters/markdown.py new file mode 100644 index 0000000..2cba575 --- /dev/null +++ b/robusta_krr/formatters/markdown.py @@ -0,0 +1,111 @@ +import itertools +from tabulate import tabulate +from typing import Any + +from robusta_krr.core.abstract import formatters +from robusta_krr.core.models.allocations import RecommendationValue, format_recommendation_value, format_diff, NONE_LITERAL, NAN_LITERAL +from robusta_krr.core.models.result import ResourceScan, ResourceType, Result +from robusta_krr.core.models.config import settings +from robusta_krr.utils import resource_units + + +def _format_request_str(item: ResourceScan, resource: ResourceType, selector: str) -> str: + allocated = getattr(item.object.allocations, selector)[resource] + info = item.recommended.info.get(resource) + recommended = getattr(item.recommended, selector)[resource] + severity = recommended.severity + + if allocated is None and recommended.value is None: + return f"{NONE_LITERAL}" + + diff = format_diff(allocated, recommended, selector) + if diff != "": + diff = f"({diff})" + + if info is None: + info_formatted = "" + else: + info_formatted = f"*({info})*" + + return ( + f"{severity.emoji} " + + diff + + " " + + format_recommendation_value(allocated) + + " -> " + + format_recommendation_value(recommended.value) + + " " + + info_formatted + ) + + +def _format_total_diff(item: ResourceScan, resource: ResourceType, pods_current: int) -> str: + selector = "requests" + allocated = getattr(item.object.allocations, selector)[resource] + recommended = getattr(item.recommended, selector)[resource] + + # if we have more than one pod, say so (this explains to the user why the total is different than the recommendation) + if pods_current == 1: + pods_info = "" + else: + pods_info = f"*({pods_current} pods)*" + + return f"{format_diff(allocated, recommended, selector, pods_current)} {pods_info}" + + +@formatters.register() +def markdown(result: Result) -> str: + """Format the result as markdown. + + :param result: The result to format. + :type result: :class:`core.result.Result` + :returns: The formatted results. + :rtype: str + """ + + cluster_count = len(set(item.object.cluster for item in result.scans)) + + headers = [] + headers.append("Number") + if cluster_count > 1 or settings.show_cluster_name: + headers.append("Cluster") + headers.append("Namespace") + headers.append("Name") + headers.append("Pods") + headers.append("Old Pods") + headers.append("Type") + headers.append("Container") + for resource in ResourceType: + headers.append(f"{resource.name} Diff") + headers.append(f"{resource.name} Requests") + headers.append(f"{resource.name} Limits") + + table = [] + for _, group in itertools.groupby( + enumerate(result.scans), key=lambda x: (x[1].object.cluster, x[1].object.namespace, x[1].object.name) + ): + group_items = list(group) + + for j, (i, item) in enumerate(group_items): + last_row = j == len(group_items) - 1 + full_info_row = j == 0 + + cells: list[Any] = [f"{i + 1}."] + if cluster_count > 1 or settings.show_cluster_name: + cells.append(item.object.cluster if full_info_row else "") + cells += [ + item.object.namespace if full_info_row else "", + item.object.name if full_info_row else "", + f"{item.object.current_pods_count}" if full_info_row else "", + f"{item.object.deleted_pods_count}" if full_info_row else "", + item.object.kind if full_info_row else "", + item.object.container + ] + + for resource in ResourceType: + cells.append(_format_total_diff(item, resource, item.object.current_pods_count)) + cells += [_format_request_str(item, resource, selector) for selector in ["requests", "limits"]] + + table.append(cells) + + return tabulate(table, headers, tablefmt="pipe")