Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ cloudcost policy run cloudcost.yml
5. View your optimization findings:
```bash
cloudcost findings list
```

To see which resources to fix first, roll findings up per resource (sorted by combined impact; `--min-findings 2` shows only resources hit by more than one check):
```bash
cloudcost findings summary
```

## Mapping a real cloud source to the FOCUS schema
Expand Down
54 changes: 54 additions & 0 deletions src/cloudcost/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,60 @@ def list_findings(severity: str = typer.Option(None, help="Filter by severity"))
except FileNotFoundError:
console.print("[yellow]No findings generated yet. Run `cloudcost policy run` first.[/yellow]")

@findings_app.command("summary")
def summarize_findings(
input: str = typer.Option("data/findings.json", help="Path to findings JSON file"),
min_findings: int = typer.Option(1, help="Only show resources with at least this many findings"),
):
"""Roll up findings per resource, sorted by combined impact."""
from rich.table import Table

try:
with open(input, "r", encoding="utf-8") as f:
findings = json.load(f)
except FileNotFoundError:
console.print("[yellow]No findings generated yet. Run `cloudcost policy run` first.[/yellow]")
return

# A resource can trip several policies (e.g. stopped-but-billing AND
# untagged). Each finding stays independent in findings.json; this is
# just a rolled-up view for deciding which resource to fix first.
groups = {}
for f in findings:
resource_id = f.get("resource_id")
if not resource_id:
continue
group = groups.setdefault(resource_id, {"policies": [], "impact": 0.0})
group["policies"].append(f.get("policy_name", "unknown"))
group["impact"] += float(f.get("estimated_impact", 0.0) or 0.0)

rows = sorted(
((rid, g) for rid, g in groups.items() if len(g["policies"]) >= min_findings),
key=lambda item: item[1]["impact"],
reverse=True,
)

if not rows:
console.print("[green]No findings to display![/green]")
return

table = Table(title=f"Findings by resource ({len(rows)} resources)")
table.add_column("Resource", overflow="fold")
table.add_column("Findings", justify="right")
table.add_column("Policies", overflow="fold")
table.add_column("Combined impact", justify="right")
for resource_id, g in rows:
table.add_row(
resource_id,
str(len(g["policies"])),
", ".join(sorted(g["policies"])),
f"${g['impact']:,.2f}",
)
console.print(table)

total_impact = sum(g["impact"] for _, g in rows)
console.print(f"[blue]Total combined impact: ${total_impact:,.2f}[/blue]")

@findings_app.command("notify")
def notify_findings(
webhook_url: str = typer.Option(..., help="Slack incoming webhook or generic webhook URL"),
Expand Down
58 changes: 58 additions & 0 deletions tests/test_findings_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import json

from typer.testing import CliRunner

from cloudcost.cli import app


runner = CliRunner()


def _write_findings(tmp_path):
findings_path = tmp_path / "findings.json"
findings_path.write_text(
json.dumps(
[
{"resource_id": "/sub/vm-a", "policy_name": "stopped_not_deallocated_vms", "severity": "high", "estimated_impact": 100.0},
{"resource_id": "/sub/vm-a", "policy_name": "untagged_resources", "severity": "low", "estimated_impact": 0.0},
{"resource_id": "/sub/disk-b", "policy_name": "unattached_disks", "severity": "medium", "estimated_impact": 250.5},
{"resource_id": None, "policy_name": "github_wasted_actions_minutes", "severity": "low", "estimated_impact": 5.0},
]
)
)
return findings_path


def test_findings_summary_groups_by_resource_sorted_by_impact(tmp_path) -> None:
findings_path = _write_findings(tmp_path)

result = runner.invoke(app, ["findings", "summary", "--input", str(findings_path)])

assert result.exit_code == 0, result.stdout
assert "2 resources" in result.stdout
# Rich folds long cells at the test runner's 80-col width, so check the
# combined impact per resource rather than the full policy list.
assert "$100.00" in result.stdout
assert "$250.50" in result.stdout
# Highest combined impact first.
assert result.stdout.index("disk-b") < result.stdout.index("vm-a")
assert "$350.50" in result.stdout


def test_findings_summary_min_findings_filters_single_hit_resources(tmp_path) -> None:
findings_path = _write_findings(tmp_path)

result = runner.invoke(app, ["findings", "summary", "--input", str(findings_path), "--min-findings", "2"])

assert result.exit_code == 0, result.stdout
assert "1 resources" in result.stdout
assert "vm-a" in result.stdout
assert "disk-b" not in result.stdout
assert "$100.00" in result.stdout


def test_findings_summary_missing_file(tmp_path) -> None:
result = runner.invoke(app, ["findings", "summary", "--input", str(tmp_path / "nope.json")])

assert result.exit_code == 0
assert "No findings generated yet" in result.stdout
Loading