diff --git a/README.md b/README.md index a0906032..d64bf5f8 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,10 @@ Most helpful flags: - `--cpu-min` Sets the minimum recommended cpu value in millicores - `--mem-min` Sets the minimum recommended memory value in MB +- `--cpu-min-diff` Sets the minimum cpu difference for recommendation in millicores +- `--mem-min-diff` Sets the minimum memory difference for recommendation in MB +- `--cpu-min-percent` Sets the minimum cpu difference in percentage for recommendation +- `--mem-min-percent` Sets the minimum memory difference in percentage for recommendation - `--history_duration` The duration of the Prometheus history data to use (in hours) More specific information on Strategy Settings can be found using diff --git a/robusta_krr/core/models/config.py b/robusta_krr/core/models/config.py index 32241ed1..9cc4afe3 100644 --- a/robusta_krr/core/models/config.py +++ b/robusta_krr/core/models/config.py @@ -32,6 +32,10 @@ class Config(pd.BaseSettings): # Value settings cpu_min_value: int = pd.Field(10, ge=0) # in millicores memory_min_value: int = pd.Field(100, ge=0) # in megabytes + cpu_min_diff: int = pd.Field(0, ge=0) # in millicores + memory_min_diff: int = pd.Field(0, ge=0) # in megabytes + cpu_min_percent: int = pd.Field(0, ge=0) # in millicores + memory_min_percent: int = pd.Field(0, ge=0) # in megabytes # Prometheus Settings prometheus_url: Optional[str] = pd.Field(None) diff --git a/robusta_krr/core/runner.py b/robusta_krr/core/runner.py index e0238302..eb8c7314 100644 --- a/robusta_krr/core/runner.py +++ b/robusta_krr/core/runner.py @@ -5,7 +5,7 @@ import sys import warnings from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Union +from typing import Optional, Union, List from datetime import timedelta, datetime from prometrix import PrometheusNotFound from rich.console import Console @@ -33,6 +33,49 @@ def custom_print(*objects, rich: bool = True, force: bool = False) -> None: if not settings.quiet or force: print_func(*objects) # type: ignore +# Helper function to make the main logic cleaner +def _meets_filter_criteria( + current_val: Optional[float], + recommended_val: Optional[float], + min_diff: float, + min_percent: float, + resource: ResourceType, +) -> bool: + """ + Checks if the difference between current and recommended values meets the threshold. + For CPU, min_diff is in millicores, values are in cores. + For Memory, min_diff is in MB, values are in bytes. + """ + current = current_val if current_val is not None else 0.0 + recommended = recommended_val if recommended_val is not None else 0.0 + + # If no change, it doesn't meet any "difference" criteria unless thresholds are zero + if current == recommended and min_diff != 0.0 and min_percent != 0.0: + return False + + # Absolute difference check + try: + abs_diff_raw = abs(recommended - current) + if resource == ResourceType.CPU: + abs_diff = abs_diff_raw * 1000 + else: + abs_diff = abs_diff_raw / (1024**2) + except TypeError: + logger.error( + f"TypeError: current_val: {current_val}, recommended_val: {recommended_val}, min_diff: {min_diff}, min_percent: {min_percent}") + return True + + if abs_diff < min_diff and min_diff != 0.0: + return False + + if min_percent != 0.0: + if current > 0: # Avoid division by zero; if current is 0, any increase is infinite percent + percent_diff = (abs_diff_raw / current) * 100 + if percent_diff < min_percent: + return False + + return True + class CriticalRunnerException(Exception): ... @@ -300,6 +343,48 @@ async def _collect_result(self) -> Result: successful_scans = [scan for scan in scans if scan is not None] + filtered_scans: List[ResourceScan] = [] + for scan in successful_scans: + if scan.object is None or scan.object.allocations is None or scan.recommended is None: + logger.debug(f"Skipping scan for {scan.object.name if scan.object else 'Unknown'} due to missing data for filtering.") + continue + + current_cpu_request = scan.object.allocations.requests.get(ResourceType.CPU) + current_memory_request = scan.object.allocations.requests.get(ResourceType.Memory) + + recommended_cpu_request = rec.value if (rec := scan.recommended.requests.get(ResourceType.CPU)) else None + recommended_memory_request = rec.value if (rec := scan.recommended.requests.get(ResourceType.Memory)) else None + + # Check CPU criteria + cpu_meets_criteria = _meets_filter_criteria( + current_val=current_cpu_request, + recommended_val=recommended_cpu_request, + min_diff=float(settings.cpu_min_diff), + min_percent=float(settings.cpu_min_percent), + resource=ResourceType.CPU, + ) + + # Check Memory criteria + memory_meets_criteria = _meets_filter_criteria( + current_val=current_memory_request, + recommended_val=recommended_memory_request, + min_diff=float(settings.memory_min_diff), + min_percent=float(settings.memory_min_percent), + resource=ResourceType.Memory, + ) + + if cpu_meets_criteria or memory_meets_criteria: + filtered_scans.append(scan) + else: + logger.debug( + f"Scan for {scan.object.name} (container: {scan.object.container}) did not meet filter criteria. " + f"CPU met: {cpu_meets_criteria}, Memory met: {memory_meets_criteria}. " + f"Current CPU: {current_cpu_request}, Rec CPU: {recommended_cpu_request}. " + f"Current Mem: {current_memory_request}, Rec Mem: {recommended_memory_request}." + ) + + logger.info(f"Gathered {len(scans)} total scans, {len(successful_scans)} were valid, {len(filtered_scans)} met filter criteria.") + if len(scans) == 0: logger.warning("Current filters resulted in no objects available to scan.") logger.warning("Try to change the filters or check if there is anything available.") @@ -308,11 +393,11 @@ async def _collect_result(self) -> Result: "Note that you are using the '*' namespace filter, which by default excludes kube-system." ) raise CriticalRunnerException("No objects available to scan.") - elif len(successful_scans) == 0: - raise CriticalRunnerException("No successful scans were made. Check the logs for more information.") + elif len(filtered_scans) == 0: + raise CriticalRunnerException("No successful filtered scans were made. Check the logs for more information.") return Result( - scans=successful_scans, + scans=filtered_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 7159bdde..ef1ff112 100644 --- a/robusta_krr/main.py +++ b/robusta_krr/main.py @@ -207,6 +207,30 @@ def run_strategy( help="Sets the minimum recommended memory value in MB.", rich_help_panel="Recommendation Settings", ), + cpu_min_diff: int = typer.Option( + 0, + "--cpu-min-diff", + help="Sets the minimum cpu difference for recommendation in millicores.", + rich_help_panel="Recommendation Settings", + ), + memory_min_diff: int = typer.Option( + 0, + "--mem-min-diff", + help="Sets the minimum memory difference for recommendation in MB.", + rich_help_panel="Recommendation Settings", + ), + cpu_min_percent: int = typer.Option( + 0, + "--cpu-min-percent", + help="Sets the minimum cpu difference in percentage for recommendation.", + rich_help_panel="Recommendation Settings", + ), + memory_min_percent: int = typer.Option( + 0, + "--mem-min-percent", + help="Sets the minimum memory difference in percentage for recommendation.", + rich_help_panel="Recommendation Settings", + ), max_workers: int = typer.Option( 10, "--max-workers", @@ -301,6 +325,10 @@ def run_strategy( verbose=verbose, cpu_min_value=cpu_min_value, memory_min_value=memory_min_value, + cpu_min_diff=cpu_min_diff, + memory_min_diff=memory_min_diff, + cpu_min_percent=cpu_min_percent, + memory_min_percent=memory_min_percent, quiet=quiet, log_to_stderr=log_to_stderr, width=width, diff --git a/tests/formatters/test_csv_formatter.py b/tests/formatters/test_csv_formatter.py index 150b2aa5..69ef5097 100644 --- a/tests/formatters/test_csv_formatter.py +++ b/tests/formatters/test_csv_formatter.py @@ -109,6 +109,10 @@ "selector": null, "cpu_min_value": 10, "memory_min_value": 100, + "cpu_min_diff": 0, + "memory_min_diff": 0, + "cpu_min_percent": 0, + "memory_min_percent": 0, "prometheus_url": null, "prometheus_auth_header": null, "prometheus_other_headers": {},