diff --git a/README.md b/README.md index 54ad50a..979f3bb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cloudcost/cli.py b/src/cloudcost/cli.py index 037bb5e..f194abc 100644 --- a/src/cloudcost/cli.py +++ b/src/cloudcost/cli.py @@ -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"), diff --git a/tests/test_findings_summary.py b/tests/test_findings_summary.py new file mode 100644 index 0000000..ca4e194 --- /dev/null +++ b/tests/test_findings_summary.py @@ -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