Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Breaking changes: respect --fileoutput in CSV and rename -f csv-export to -f csv #290

Merged
merged 9 commits into from
Jun 15, 2024
26 changes: 10 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,15 @@ Read more about [how KRR works](#how-krr-works)
| Resource Recommendations 💡 | ✅ CPU/Memory requests and limits | ✅ CPU/Memory requests and limits |
| Installation Location 🌍 | ✅ Not required to be installed inside the cluster, can be used on your own device, connected to a cluster | ❌ Must be installed inside the cluster |
| Workload Configuration 🔧 | ✅ No need to configure a VPA object for each workload | ❌ Requires VPA object configuration for each workload |
| Immediate Results ⚡ | ✅ Gets results immediately (given Prometheus is running) | ❌ Requires time to gather data and provide recommendations |
| Reporting 📊 | ✅ Detailed CLI Report, web UI in [Robusta.dev](https://home.robusta.dev/) | ❌ Not supported |
| Immediate Results ⚡ | ✅ Gets results immediately (given Prometheus is running) | ❌ Requires time to gather data and provide recommendations |
| Reporting 📊 | ✅ Json, CSV, Markdown, [Web UI](#free-ui-for-krr-recommendations), and more! | ❌ Not supported |
| Extensibility 🔧 | ✅ Add your own strategies with few lines of Python | :warning: Limited extensibility |
| Explainability 📖 | ✅ [See graphs explaining the recommendations](#free-krr-ui-on-robusta-saas) | ❌ Not supported |
| Custom Metrics 📏 | 🔄 Support in future versions | ❌ Not supported |
| Custom Resources 🎛️ | 🔄 Support in future versions (e.g., GPU) | ❌ Not supported |
| Autoscaling 🔀 | 🔄 Support in future versions | ✅ Automatic application of recommendations |
| Default History 🕒 | 14 days | 8 days |
| Supports HPA 🔥 | ✅ Enable using `--allow-hpa` flag | ❌ Not supported |
| Default History 🕒 | 14 days | 8 days |
| Supports HPA 🔥 | ✅ Enable using `--allow-hpa` flag | ❌ Not supported |


<!-- GETTING STARTED -->
Expand Down Expand Up @@ -328,32 +328,26 @@ krr simple -c my-cluster-1 -c my-cluster-2
</details>

<details>
<summary>Customize output (JSON, YAML, and more</summary>
<summary>Output formats for reporting (JSON, YAML, CSV, and more)</summary>

Currently KRR ships with a few formatters to represent the scan data:

- `table` - a pretty CLI table used by default, powered by [Rich](https://github.com/Textualize/rich) library
- `json`
- `yaml`
- `pprint` - data representation from python's pprint library
- `csv_export` - export data to a csv file in the current directory
- `csv` - export data to a csv file in the current directory

To run a strategy with a selected formatter, add a `-f` flag:
To run a strategy with a selected formatter, add a `-f` flag. Usually this should be combined with `--fileoutput <filename>` to write clean output to file without logs:

```sh
krr simple -f json
krr simple -f json --fileoutput krr-report.json
```

For JSON output, add --logtostderr so no logs go to the result file:
If you prefer, you can also use `--logtostderr` to get clean formatted output in one file and error logs in another:

```sh
krr simple --logtostderr -f json > result.json
```

For YAML output, do the same:

```sh
krr simple --logtostderr -f yaml > result.yaml
krr simple --logtostderr -f json > result.json 2> logs-and-errors.log
```
</details>

Expand Down
25 changes: 24 additions & 1 deletion robusta_krr/core/models/allocations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,30 @@ class ResourceType(str, enum.Enum):

Self = TypeVar("Self", bound="ResourceAllocations")


NONE_LITERAL = "unset"
NAN_LITERAL = "?"

def format_recommendation_value(value: RecommendationValue) -> str:
if value is None:
return NONE_LITERAL
elif isinstance(value, str):
return NAN_LITERAL
else:
return resource_units.format(value)

def format_diff(allocated, recommended, selector, multiplier=1, colored=False) -> str:
if recommended is None or isinstance(recommended.value, str) or selector != "requests":
return ""
else:
reccomended_val = recommended.value if isinstance(recommended.value, (int, float)) else 0
allocated_val = allocated if isinstance(allocated, (int, float)) else 0
diff_val = reccomended_val - allocated_val
if colored:
diff_sign = "[green]+[/green]" if diff_val >= 0 else "[red]-[/red]"
else:
diff_sign = "+" if diff_val >= 0 else "-"
return f"{diff_sign}{format_recommendation_value(abs(diff_val) * multiplier)}"

class ResourceAllocations(pd.BaseModel):
requests: dict[ResourceType, RecommendationValue]
limits: dict[ResourceType, RecommendationValue]
Expand Down
3 changes: 2 additions & 1 deletion robusta_krr/core/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ class Config(pd.BaseSettings):
log_to_stderr: bool
width: Optional[int] = pd.Field(None, ge=1)

# Outputs Settings
# Output Settings
file_output: Optional[str] = pd.Field(None)
file_output_dynamic = bool = pd.Field(False)
slack_output: Optional[str] = pd.Field(None)

other_args: dict[str, Any]
Expand Down
19 changes: 14 additions & 5 deletions robusta_krr/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import warnings
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Union
from datetime import timedelta
from datetime import timedelta, datetime
from prometrix import PrometheusNotFound
from rich.console import Console
from slack_sdk import WebClient
Expand Down Expand Up @@ -108,14 +108,23 @@ def _process_result(self, result: Result) -> None:

custom_print(formatted, rich=rich, force=True)

if settings.file_output or settings.slack_output:
if settings.file_output:
if settings.file_output_dynamic or settings.file_output or settings.slack_output:
if settings.file_output_dynamic:
current_datetime = datetime.now().strftime("%Y%m%d%H%M%S")
file_name = f"krr-{current_datetime}.{settings.format}"
logger.info(f"Writing output to file: {file_name}")
elif settings.file_output:
file_name = settings.file_output
elif settings.slack_output:
file_name = settings.slack_output

with open(file_name, "w") as target_file:
console = Console(file=target_file, width=settings.width)
console.print(formatted)
# don't use rich when writing a csv to avoid line wrapping etc
if settings.format == "csv":
target_file.write(formatted)
else:
console = Console(file=target_file, width=settings.width)
console.print(formatted)
if settings.slack_output:
client = WebClient(os.environ["SLACK_BOT_TOKEN"])
warnings.filterwarnings("ignore", category=UserWarning)
Expand Down
111 changes: 38 additions & 73 deletions robusta_krr/formatters/csv.py
Original file line number Diff line number Diff line change
@@ -1,111 +1,76 @@
import itertools
import csv

import logging

import io

from robusta_krr.core.abstract import formatters
from robusta_krr.core.models.allocations import RecommendationValue
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.utils import resource_units
import datetime

logger = logging.getLogger("krr")

NONE_LITERAL = "unset"
NAN_LITERAL = "?"


def _format(value: RecommendationValue) -> str:
if value is None:
return NONE_LITERAL
elif isinstance(value, str):
return NAN_LITERAL
else:
return resource_units.format(value)


def __calc_diff(allocated, recommended, selector, multiplier=1) -> str:
if recommended is None or isinstance(recommended.value, str) or selector != "requests":
return ""
else:
reccomended_val = recommended.value if isinstance(recommended.value, (int, float)) else 0
allocated_val = allocated if isinstance(allocated, (int, float)) else 0
diff_val = reccomended_val - allocated_val
diff_sign = "+" if diff_val >= 0 else "-"
return f"{diff_sign}{_format(abs(diff_val) * multiplier)}"


def _format_request_str(item: ResourceScan, resource: ResourceType, selector: str) -> str:
allocated = getattr(item.object.allocations, selector)[resource]
recommended = getattr(item.recommended, selector)[resource]

if allocated is None and recommended.value is None:
return f"{NONE_LITERAL}"

diff = __calc_diff(allocated, recommended, selector)
diff = format_diff(allocated, recommended, selector)
if diff != "":
diff = f"({diff}) "

return (
diff
+ _format(allocated)
+ format_recommendation_value(allocated)
+ " -> "
+ _format(recommended.value)
+ format_recommendation_value(recommended.value)
)


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]

return __calc_diff(allocated, recommended, selector, pods_current)


@formatters.register()
def csv_export(result: Result) -> str:

current_datetime = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
file_path = f"krr-{current_datetime}.csv"
return format_diff(allocated, recommended, selector, pods_current)

@formatters.register("csv")
def csv_exporter(result: Result) -> str:
# We need to order the resource columns so that they are in the format of Namespace,Name,Pods,Old Pods,Type,Container,CPU Diff,CPU Requests,CPU Limits,Memory Diff,Memory Requests,Memory Limits
resource_columns = []
for resource in ResourceType:
resource_columns.append(f"{resource.name} Diff")
resource_columns.append(f"{resource.name} Requests")
resource_columns.append(f"{resource.name} Limits")

with open(file_path, 'w+', newline='') as csvfile:
csv_writer = csv.writer(csvfile)
csv_writer.writerow([
"Namespace", "Name", "Pods", "Old Pods", "Type", "Container",
*resource_columns

])

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):
full_info_row = j == 0

row = [
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:
row.append(_format_total_diff(item, resource, item.object.current_pods_count))
row += [_format_request_str(item, resource, selector) for selector in ["requests", "limits"]]

csv_writer.writerow(row)
output = io.StringIO()
csv_writer = csv.writer(output)
csv_writer.writerow([
"Namespace", "Name", "Pods", "Old Pods", "Type", "Container",
*resource_columns
])

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):
full_info_row = j == 0

row = [
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:
row.append(_format_total_diff(item, resource, item.object.current_pods_count))
row += [_format_request_str(item, resource, selector) for selector in ["requests", "limits"]]

csv_writer.writerow(row)

logger.info("CSV File: %s", file_path)
return ""
return output.getvalue()
39 changes: 11 additions & 28 deletions robusta_krr/formatters/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,11 @@
from rich.table import Table

from robusta_krr.core.abstract import formatters
from robusta_krr.core.models.allocations import RecommendationValue
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

NONE_LITERAL = "unset"
NAN_LITERAL = "?"


def _format(value: RecommendationValue) -> str:
if value is None:
return NONE_LITERAL
elif isinstance(value, str):
return NAN_LITERAL
else:
return resource_units.format(value)


def __calc_diff(allocated, recommended, selector, multiplier=1) -> str:
if recommended is None or isinstance(recommended.value, str) or selector != "requests":
return ""
else:
reccomended_val = recommended.value if isinstance(recommended.value, (int, float)) else 0
allocated_val = allocated if isinstance(allocated, (int, float)) else 0
diff_val = reccomended_val - allocated_val
diff_sign = "[green]+[/green]" if diff_val >= 0 else "[red]-[/red]"
return f"{diff_sign}{_format(abs(diff_val) * multiplier)}"


DEFAULT_INFO_COLOR = "grey27"
INFO_COLORS: dict[str, str] = {
Expand All @@ -48,7 +25,7 @@ def _format_request_str(item: ResourceScan, resource: ResourceType, selector: st
if allocated is None and recommended.value is None:
return f"[{severity.color}]{NONE_LITERAL}[/{severity.color}]"

diff = __calc_diff(allocated, recommended, selector)
diff = format_diff(allocated, recommended, selector, colored=True)
if diff != "":
diff = f"({diff}) "

Expand All @@ -61,9 +38,9 @@ def _format_request_str(item: ResourceScan, resource: ResourceType, selector: st
return (
diff
+ f"[{severity.color}]"
+ _format(allocated)
+ format_recommendation_value(allocated)
+ " -> "
+ _format(recommended.value)
+ format_recommendation_value(recommended.value)
+ f"[/{severity.color}]"
+ info_formatted
)
Expand All @@ -74,7 +51,13 @@ def _format_total_diff(item: ResourceScan, resource: ResourceType, pods_current:
allocated = getattr(item.object.allocations, selector)[resource]
recommended = getattr(item.recommended, selector)[resource]

return __calc_diff(allocated, recommended, selector, pods_current)
# 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"\n({pods_current} pods)"

return f"{format_diff(allocated, recommended, selector, pods_current, colored=True)}{pods_info}"


@formatters.register(rich_console=True)
Expand Down
6 changes: 5 additions & 1 deletion robusta_krr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,10 @@ def run_strategy(
rich_help_panel="Logging Settings",
),
file_output: Optional[str] = typer.Option(
None, "--fileoutput", help="Print the output to a file", rich_help_panel="Output Settings"
None, "--fileoutput", help="Filename to write output to (if not specified, file output is disabled)", rich_help_panel="Output Settings"
),
file_output_dynamic: bool = typer.Option(
False, "--fileoutput-dynamic", help="Ignore --fileoutput and write files to the current directory in the format krr-{datetime}.{format} (e.g. krr-20240518223924.csv)", rich_help_panel="Output Settings"
),
slack_output: Optional[str] = typer.Option(
None,
Expand Down Expand Up @@ -279,6 +282,7 @@ def run_strategy(
log_to_stderr=log_to_stderr,
width=width,
file_output=file_output,
file_output_dynamic=file_output_dynamic,
slack_output=slack_output,
strategy=_strategy_name,
other_args=strategy_args,
Expand Down
Loading