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
13 changes: 12 additions & 1 deletion .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,18 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pyinstaller
pip install pyinstaller build

- name: Build Python package
if: github.event_name == 'release'
run: |
python -m build

- name: Publish package to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}

- name: Find adbc_driver_postgresql static version file
id: adbc
Expand Down
22 changes: 22 additions & 0 deletions policies/idle_apim.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Real check: Azure API Management instances with zero requests over the
-- lookback window are still billed for the fixed gateway tier, even when the
-- service is technically online but unused.
SELECT
'azure' AS provider,
resource_id,
'API Management' AS service_name,
CASE
WHEN sku = 'Developer' THEN 0.00
WHEN sku = 'Basic' THEN 30.00
WHEN sku = 'Standard' THEN 30.00
WHEN sku = 'Premium' THEN 30.00
ELSE 0.00
END * 1 AS billed_cost,
resource_name,
sku,
unit_count,
total_requests,
lookback_days,
'API Management with 0 requests over ' || lookback_days || ' days (' || sku || ')' AS evidence_reason
FROM fact_idle_apim
WHERE total_requests = 0;
19 changes: 19 additions & 0 deletions policies/idle_fleet_manager.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Real check: Azure Kubernetes Fleet Manager hubs bill a fixed monthly fee
-- when enabled, even when no member clusters are attached or no update runs
-- are happening across the lookback window.
SELECT
'azure' AS provider,
resource_id,
'Fleet Manager' AS service_name,
CASE
WHEN sku = 'Standard' THEN 30.00
ELSE 0.00
END AS billed_cost,
resource_name,
sku,
member_cluster_count,
update_runs,
lookback_days,
'Fleet Manager with 0 member clusters over ' || lookback_days || ' days (' || sku || ')' AS evidence_reason
FROM fact_idle_fleet_manager
WHERE member_cluster_count = 0;
17 changes: 17 additions & 0 deletions policies/idle_managed_hsm.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Real check: Azure Managed HSM instances with zero operations over the
-- lookback window still incur a fixed per-instance fee in Standard tiers.
SELECT
'azure' AS provider,
resource_id,
'Managed HSM' AS service_name,
CASE
WHEN sku = 'Standard_B1' THEN 10.00
ELSE 0.00
END AS billed_cost,
resource_name,
sku,
operation_count,
lookback_days,
'Managed HSM with 0 operations over ' || lookback_days || ' days (' || sku || ')' AS evidence_reason
FROM fact_idle_managed_hsm
WHERE operation_count = 0;
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
[project]
name = "cloudcost-cli"
version = "0.1.0"
description = "Add your description here"
description = "Azure-first FinOps waste detection CLI for cloud cost governance"
readme = "README.md"
authors = [
{ name = "Raphael", email = "rdgabmomoh@gmail.com" }
]
license = { text = "MIT" }
requires-python = ">=3.13"

[project.urls]
Homepage = "https://github.com/raphgm/cloudcost-cli"
Repository = "https://github.com/raphgm/cloudcost-cli"
Issues = "https://github.com/raphgm/cloudcost-cli/issues"
dependencies = [
"adbc-driver-postgresql>=1.12.0",
"azure-identity>=1.25.3",
Expand Down
125 changes: 122 additions & 3 deletions src/cloudcost/cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import html
import json
import os
import sys
from pathlib import Path

import requests
import typer
from rich.console import Console

Expand All @@ -11,11 +18,8 @@ def init():
console.print("Created sample [bold]cloudcost.yml[/bold]")

from cloudcost.config.loader import load_config
import os
import sys

from cloudcost.core.capabilities import capabilities_registry
import json

providers_app = typer.Typer(help="Manage and discover cloud provider capabilities.")
app.add_typer(providers_app, name="providers")
Expand Down Expand Up @@ -137,6 +141,37 @@ 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("notify")
def notify_findings(
webhook_url: str = typer.Option(..., help="Slack incoming webhook or generic webhook URL"),
input: str = typer.Option("data/findings.json", help="Path to findings JSON file"),
):
"""Post a brief findings summary to a webhook endpoint."""
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

if not findings:
message = "CloudCost findings summary: no new findings detected."
else:
total_impact = sum(float(f.get("estimated_impact", 0.0) or 0.0) for f in findings)
message = (
f"CloudCost findings summary: {len(findings)} findings, total at risk: ${total_impact:,.2f}.\n"
+ "\n".join(
f"- {f.get('policy_name', 'unknown')} ({f.get('service_name', 'unknown')}): {f.get('severity', 'unknown').upper()} - ${float(f.get('estimated_impact', 0.0) or 0.0):,.2f}"
for f in findings[:5]
)
)

payload = {"text": message}
response = requests.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()

console.print(f"[green]Notification sent to {webhook_url}[/green]")

@app.command()
def validate(config: str = typer.Argument("cloudcost.yml", help="Path to pipeline configuration file")):
"""Validate a CloudCost pipeline configuration file."""
Expand Down Expand Up @@ -189,6 +224,90 @@ def iac_map(
console.print(f"[red]Failed to map IaC: {e}[/red]")
sys.exit(1)

@app.command()
def report(
input: str = typer.Option("data/findings.json", "--input", help="Path to findings JSON file"),
output: str = typer.Option("data/report.html", "--output", help="Path to write the HTML report"),
):
"""Render a static HTML summary of current findings."""
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

output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)

findings = sorted(findings, key=lambda f: float(f.get("estimated_impact", 0.0) or 0.0), reverse=True)
total_impact = sum(float(f.get("estimated_impact", 0.0) or 0.0) for f in findings)

rows_html = []
for finding in findings:
policy_name = html.escape(str(finding.get("policy_name", "N/A")))
provider = html.escape(str(finding.get("provider", "N/A")))
service = html.escape(str(finding.get("service_name", "N/A")))
severity = html.escape(str(finding.get("severity", "unknown")).upper())
impact = float(finding.get("estimated_impact", 0.0) or 0.0)
rows_html.append(
"""
<tr>
<td>{policy_name}</td>
<td>{provider}</td>
<td>{service}</td>
<td>{severity}</td>
<td>${impact:,.2f}</td>
</tr>
""".format(policy_name=policy_name, provider=provider, service=service, severity=severity, impact=impact)
)

table_body = "\n".join(rows_html) if rows_html else "<tr><td colspan='5'>No findings</td></tr>"

html_report = f"""<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<title>CloudCost Findings Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 2rem; color: #1f2937; }}
h1 {{ margin-bottom: 0.5rem; }}
.summary {{ margin: 1rem 0 2rem; padding: 1rem; background: #f3f4f6; border-radius: 8px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 1rem; }}
th, td {{ padding: 0.75rem; border: 1px solid #d1d5db; text-align: left; }}
th {{ background: #e5e7eb; }}
.muted {{ color: #4b5563; }}
</style>
</head>
<body>
<h1>CloudCost Findings Report</h1>
<div class=\"summary\">
<div><strong>Total findings:</strong> {len(findings)}</div>
<div><strong>Total at risk:</strong> ${total_impact:,.2f}</div>
</div>
<table>
<thead>
<tr>
<th>Policy</th>
<th>Provider</th>
<th>Service</th>
<th>Severity</th>
<th>Impact ($)</th>
</tr>
</thead>
<tbody>
{table_body}
</tbody>
</table>
</body>
</html>
"""

with open(output_path, "w", encoding="utf-8") as f:
f.write(html_report)

console.print(f"[green]Report written to {output_path}[/green]")

@app.command()
def dashboard():
"""Launch the interactive CloudCost terminal dashboard."""
Expand Down
31 changes: 31 additions & 0 deletions tests/policies/test_idle_apim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pyarrow as pa


def apim_rows(*rows: tuple[str, str, int, float]) -> pa.Table:
return pa.table(
{
"resource_id": pa.array([r[0] for r in rows], type=pa.string()),
"resource_name": pa.array([f"apim-{r[0]}" for r in rows], type=pa.string()),
"sku": pa.array([r[1] for r in rows], type=pa.string()),
"unit_count": pa.array([r[2] for r in rows], type=pa.int64()),
"total_requests": pa.array([r[3] for r in rows], type=pa.float64()),
"lookback_days": pa.array([7 for _ in rows], type=pa.int64()),
}
)


def test_apim_zero_requests_is_reported(run_policy) -> None:
findings = run_policy("idle_apim", {"fact_idle_apim": apim_rows(("apim1", "Standard", 1, 0.0))})

assert len(findings) == 1
finding = findings[0]
assert finding.provider == "azure"
assert finding.service_name == "API Management"
assert finding.estimated_impact == 30.0
assert finding.evidence["evidence_reason"] == "API Management with 0 requests over 7 days (Standard)"


def test_apim_with_activity_is_not_reported(run_policy) -> None:
findings = run_policy("idle_apim", {"fact_idle_apim": apim_rows(("apim1", "Standard", 1, 10.0))})

assert findings == []
31 changes: 31 additions & 0 deletions tests/policies/test_idle_fleet_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pyarrow as pa


def fleet_rows(*rows: tuple[str, str, int, float]) -> pa.Table:
return pa.table(
{
"resource_id": pa.array([r[0] for r in rows], type=pa.string()),
"resource_name": pa.array([f"fleet-{r[0]}" for r in rows], type=pa.string()),
"sku": pa.array([r[1] for r in rows], type=pa.string()),
"member_cluster_count": pa.array([r[2] for r in rows], type=pa.int64()),
"update_runs": pa.array([r[3] for r in rows], type=pa.float64()),
"lookback_days": pa.array([7 for _ in rows], type=pa.int64()),
}
)


def test_fleet_with_zero_member_clusters_is_reported(run_policy) -> None:
findings = run_policy("idle_fleet_manager", {"fact_idle_fleet_manager": fleet_rows(("fleet1", "Standard", 0, 0.0))})

assert len(findings) == 1
finding = findings[0]
assert finding.provider == "azure"
assert finding.service_name == "Fleet Manager"
assert finding.estimated_impact == 30.0
assert finding.evidence["evidence_reason"] == "Fleet Manager with 0 member clusters over 7 days (Standard)"


def test_fleet_with_real_activity_is_not_reported(run_policy) -> None:
findings = run_policy("idle_fleet_manager", {"fact_idle_fleet_manager": fleet_rows(("fleet1", "Standard", 1, 10.0))})

assert findings == []
30 changes: 30 additions & 0 deletions tests/policies/test_idle_managed_hsm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import pyarrow as pa


def hsm_rows(*rows: tuple[str, str, int, float]) -> pa.Table:
return pa.table(
{
"resource_id": pa.array([r[0] for r in rows], type=pa.string()),
"resource_name": pa.array([f"hsm-{r[0]}" for r in rows], type=pa.string()),
"sku": pa.array([r[1] for r in rows], type=pa.string()),
"operation_count": pa.array([r[2] for r in rows], type=pa.int64()),
"lookback_days": pa.array([7 for _ in rows], type=pa.int64()),
}
)


def test_managed_hsm_zero_operations_is_reported(run_policy) -> None:
findings = run_policy("idle_managed_hsm", {"fact_idle_managed_hsm": hsm_rows(("hsm1", "Standard_B1", 0, 0.0))})

assert len(findings) == 1
finding = findings[0]
assert finding.provider == "azure"
assert finding.service_name == "Managed HSM"
assert finding.estimated_impact == 10.0
assert finding.evidence["evidence_reason"] == "Managed HSM with 0 operations over 7 days (Standard_B1)"


def test_managed_hsm_with_activity_is_not_reported(run_policy) -> None:
findings = run_policy("idle_managed_hsm", {"fact_idle_managed_hsm": hsm_rows(("hsm1", "Standard_B1", 2, 10.0))})

assert findings == []
Loading
Loading