See below on filtering output from a centralized prometheus, so it matches only one cluster
diff --git a/robusta_krr/core/models/config.py b/robusta_krr/core/models/config.py index e423c6c7..3041295e 100644 --- a/robusta_krr/core/models/config.py +++ b/robusta_krr/core/models/config.py @@ -15,6 +15,7 @@ from robusta_krr.core.abstract import formatters from robusta_krr.core.abstract.strategies import AnyStrategy, BaseStrategy from robusta_krr.core.models.objects import KindLiteral +from robusta_krr.core.models.severity import Severity logger = logging.getLogger("krr") @@ -35,6 +36,10 @@ class Config(pd.BaseSettings): cpu_min_value: int = pd.Field(10, ge=0) # in millicores memory_min_value: int = pd.Field(100, ge=0) # in megabytes + # Only report workloads whose recommendation is at least this severe. + # None means report everything (the default). + severity_threshold: Optional[Severity] = pd.Field(None) + # Prometheus Settings prometheus_url: Optional[str] = pd.Field(None) prometheus_auth_header: Optional[pd.SecretStr] = pd.Field(None) diff --git a/robusta_krr/core/models/result.py b/robusta_krr/core/models/result.py index b451fc3d..0ff517fb 100644 --- a/robusta_krr/core/models/result.py +++ b/robusta_krr/core/models/result.py @@ -11,6 +11,16 @@ from robusta_krr.core.models.config import Config +def filter_scans_by_severity(scans: list[ResourceScan], threshold: Optional[Severity]) -> list[ResourceScan]: + """Keep only scans whose severity is at least `threshold`. + + Returns the scans unchanged when no threshold is given. + """ + if threshold is None: + return list(scans) + return [scan for scan in scans if scan.severity.is_at_least(threshold)] + + class Recommendation(pd.BaseModel): value: RecommendationValue severity: Severity diff --git a/robusta_krr/core/models/severity.py b/robusta_krr/core/models/severity.py index 7d9ec130..71cb39cd 100644 --- a/robusta_krr/core/models/severity.py +++ b/robusta_krr/core/models/severity.py @@ -30,6 +30,20 @@ def color(self) -> str: self.CRITICAL: "red", }[self] + def is_at_least(self, threshold: Severity) -> bool: + """Whether this severity represents a change at least as large as `threshold`. + + Severities are ordered by the size of the underlying change + (GOOD < OK < WARNING < CRITICAL). UNKNOWN means we could not compare the + current and recommended values, so it is always considered significant + enough to keep (we don't want to silently drop something we couldn't + measure). A threshold of UNKNOWN keeps everything. + """ + order = [Severity.GOOD, Severity.OK, Severity.WARNING, Severity.CRITICAL] + if self is Severity.UNKNOWN or threshold not in order: + return True + return order.index(self) >= order.index(threshold) + @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 23a63fd4..6be4d3d2 100644 --- a/robusta_krr/core/runner.py +++ b/robusta_krr/core/runner.py @@ -21,7 +21,14 @@ from robusta_krr.core.integrations.prometheus import ClusterNotSpecifiedException, PrometheusMetricsLoader from robusta_krr.core.models.config import settings from robusta_krr.core.models.objects import K8sObjectData -from robusta_krr.core.models.result import ResourceAllocations, ResourceScan, ResourceType, Result, StrategyData +from robusta_krr.core.models.result import ( + ResourceAllocations, + ResourceScan, + ResourceType, + Result, + StrategyData, + filter_scans_by_severity, +) from robusta_krr.utils.intro import load_intro_message from robusta_krr.utils.progress_bar import ProgressBar from robusta_krr.utils.version import get_version, load_latest_version @@ -459,8 +466,10 @@ async def _collect_result(self) -> Result: elif len(successful_scans) == 0: raise CriticalRunnerException("No successful scans were made. Check the logs for more information.") + reported_scans = filter_scans_by_severity(successful_scans, settings.severity_threshold) + return Result( - scans=successful_scans, + scans=reported_scans, description=f"[b]{self._strategy.display_name.title()} Strategy[/b]\n\n{self._strategy.description}", strategy=StrategyData( name=str(self._strategy).lower(), diff --git a/robusta_krr/main.py b/robusta_krr/main.py index c9da3009..24d5988a 100644 --- a/robusta_krr/main.py +++ b/robusta_krr/main.py @@ -17,6 +17,7 @@ from robusta_krr.core.abstract import formatters from robusta_krr.core.abstract.strategies import BaseStrategy from robusta_krr.core.models.config import Config +from robusta_krr.core.models.severity import Severity from robusta_krr.core.runner import Runner, publish_input_error from robusta_krr.utils.version import get_version @@ -263,6 +264,13 @@ def run_strategy( help="Whether to include the severity in the output or not", rich_help_panel="Output Settings", ), + severity_threshold: Optional[Severity] = typer.Option( + None, + "--severity-threshold", + case_sensitive=False, + help="Only report workloads whose recommendation is at least this severe (GOOD, OK, WARNING or CRITICAL). By default all workloads are reported.", + rich_help_panel="Output Settings", + ), verbose: bool = typer.Option( False, "--verbose", "-v", help="Enable verbose mode", rich_help_panel="Logging Settings" ), @@ -390,6 +398,7 @@ def run_strategy( verbose=verbose, cpu_min_value=cpu_min_value, memory_min_value=memory_min_value, + severity_threshold=severity_threshold, quiet=quiet, log_to_stderr=log_to_stderr, width=width, diff --git a/tests/models/test_severity.py b/tests/models/test_severity.py new file mode 100644 index 00000000..577e2569 --- /dev/null +++ b/tests/models/test_severity.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +import pytest + +from robusta_krr.core.models.result import filter_scans_by_severity +from robusta_krr.core.models.severity import Severity + + +@pytest.mark.parametrize( + "severity, threshold, expected", + [ + (Severity.CRITICAL, Severity.WARNING, True), + (Severity.WARNING, Severity.WARNING, True), + (Severity.OK, Severity.WARNING, False), + (Severity.GOOD, Severity.WARNING, False), + (Severity.GOOD, Severity.GOOD, True), + # UNKNOWN cannot be measured, so it is never dropped + (Severity.UNKNOWN, Severity.CRITICAL, True), + # an UNKNOWN threshold keeps everything + (Severity.GOOD, Severity.UNKNOWN, True), + ], +) +def test_is_at_least(severity: Severity, threshold: Severity, expected: bool) -> None: + assert severity.is_at_least(threshold) is expected + + +def test_filter_scans_by_severity_no_threshold() -> None: + scans = [SimpleNamespace(severity=s) for s in Severity] + assert filter_scans_by_severity(scans, None) == scans + + +def test_filter_scans_by_severity_warning() -> None: + scans = [ + SimpleNamespace(severity=s) for s in [Severity.GOOD, Severity.WARNING, Severity.CRITICAL, Severity.UNKNOWN] + ] + kept = [scan.severity for scan in filter_scans_by_severity(scans, Severity.WARNING)] + # GOOD is dropped, order is otherwise preserved, UNKNOWN is always kept + assert kept == [Severity.WARNING, Severity.CRITICAL, Severity.UNKNOWN] diff --git a/tests/test_runner.py b/tests/test_runner.py index dcf4e19b..dc0c6208 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -19,3 +19,17 @@ def test_exclude_severity_option(args: list[str], expected_exit_code: int) -> None: result: Result = runner.invoke(app, ["simple", *args]) assert result.exit_code == expected_exit_code + + +@pytest.mark.parametrize("threshold", ["good", "WARNING", "critical"]) +def test_severity_threshold_option(threshold: str) -> None: + result: Result = runner.invoke(app, ["simple", "-q", "--severity-threshold", threshold]) + try: + assert result.exit_code == 0, result.stdout + except AssertionError as e: + raise e from result.exception + + +def test_severity_threshold_rejects_unknown_value() -> None: + result: Result = runner.invoke(app, ["simple", "-q", "--severity-threshold", "nope"]) + assert result.exit_code == 2