diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 2d9f395..6adce69 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -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
diff --git a/policies/idle_apim.sql b/policies/idle_apim.sql
new file mode 100644
index 0000000..796a539
--- /dev/null
+++ b/policies/idle_apim.sql
@@ -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;
diff --git a/policies/idle_fleet_manager.sql b/policies/idle_fleet_manager.sql
new file mode 100644
index 0000000..ef375b1
--- /dev/null
+++ b/policies/idle_fleet_manager.sql
@@ -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;
diff --git a/policies/idle_managed_hsm.sql b/policies/idle_managed_hsm.sql
new file mode 100644
index 0000000..b5b255e
--- /dev/null
+++ b/policies/idle_managed_hsm.sql
@@ -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;
diff --git a/pyproject.toml b/pyproject.toml
index 076c513..5342aef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/src/cloudcost/cli.py b/src/cloudcost/cli.py
index 57132e9..8f7058c 100644
--- a/src/cloudcost/cli.py
+++ b/src/cloudcost/cli.py
@@ -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
@@ -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")
@@ -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."""
@@ -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(
+ """
+
+ | {policy_name} |
+ {provider} |
+ {service} |
+ {severity} |
+ ${impact:,.2f} |
+
+ """.format(policy_name=policy_name, provider=provider, service=service, severity=severity, impact=impact)
+ )
+
+ table_body = "\n".join(rows_html) if rows_html else "| No findings |
"
+
+ html_report = f"""
+
+
+
+ CloudCost Findings Report
+
+
+
+ CloudCost Findings Report
+
+
Total findings: {len(findings)}
+
Total at risk: ${total_impact:,.2f}
+
+
+
+
+ | Policy |
+ Provider |
+ Service |
+ Severity |
+ Impact ($) |
+
+
+
+ {table_body}
+
+
+
+
+"""
+
+ 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."""
diff --git a/tests/policies/test_idle_apim.py b/tests/policies/test_idle_apim.py
new file mode 100644
index 0000000..6da50a6
--- /dev/null
+++ b/tests/policies/test_idle_apim.py
@@ -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 == []
diff --git a/tests/policies/test_idle_fleet_manager.py b/tests/policies/test_idle_fleet_manager.py
new file mode 100644
index 0000000..5e73623
--- /dev/null
+++ b/tests/policies/test_idle_fleet_manager.py
@@ -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 == []
diff --git a/tests/policies/test_idle_managed_hsm.py b/tests/policies/test_idle_managed_hsm.py
new file mode 100644
index 0000000..82ff5e7
--- /dev/null
+++ b/tests/policies/test_idle_managed_hsm.py
@@ -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 == []
diff --git a/tests/test_findings_notify.py b/tests/test_findings_notify.py
new file mode 100644
index 0000000..359d6ba
--- /dev/null
+++ b/tests/test_findings_notify.py
@@ -0,0 +1,57 @@
+import json
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+from typer.testing import CliRunner
+
+from cloudcost.cli import app
+
+
+runner = CliRunner()
+
+
+class CaptureHandler(BaseHTTPRequestHandler):
+ payload = None
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length", "0"))
+ body = self.rfile.read(length)
+ CaptureHandler.payload = json.loads(body.decode("utf-8"))
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b"ok")
+
+ def log_message(self, format, *args):
+ return
+
+
+def test_findings_notify_posts_summary_to_webhook(tmp_path) -> None:
+ findings_path = tmp_path / "findings.json"
+ findings_path.write_text(
+ json.dumps(
+ [
+ {"policy_name": "idle_app_service_plans", "provider": "azure", "service_name": "App Service Plan", "severity": "high", "estimated_impact": 125.5},
+ {"policy_name": "idle_bastion", "provider": "azure", "service_name": "Bastion", "severity": "medium", "estimated_impact": 50.25},
+ ]
+ )
+ )
+
+ server = HTTPServer(("127.0.0.1", 0), CaptureHandler)
+ port = server.server_address[1]
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ try:
+ result = runner.invoke(
+ app,
+ ["findings", "notify", "--webhook-url", f"http://127.0.0.1:{port}", "--input", str(findings_path)],
+ )
+ finally:
+ server.shutdown()
+ server.server_close()
+
+ assert result.exit_code == 0, result.stdout
+ assert CaptureHandler.payload is not None
+ assert CaptureHandler.payload["text"].startswith("CloudCost findings summary")
+ assert "2 findings" in CaptureHandler.payload["text"]
+ assert "$175.75" in CaptureHandler.payload["text"]
diff --git a/tests/test_package_metadata.py b/tests/test_package_metadata.py
new file mode 100644
index 0000000..b78c63a
--- /dev/null
+++ b/tests/test_package_metadata.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+
+
+def test_project_metadata_is_pypi_ready() -> None:
+ pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
+ content = pyproject.read_text()
+
+ assert 'name = "cloudcost-cli"' in content
+ assert 'readme = "README.md"' in content
+ assert 'authors = [' in content
+ assert 'Homepage' in content or '[project.urls]' in content
+ assert 'cloudcost = "cloudcost.cli:app"' in content
diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py
new file mode 100644
index 0000000..67e3bf4
--- /dev/null
+++ b/tests/test_release_workflow.py
@@ -0,0 +1,10 @@
+from pathlib import Path
+
+
+def test_release_workflow_uploads_to_pypi() -> None:
+ workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "build-release.yml"
+ content = workflow.read_text()
+
+ assert "python -m build" in content
+ assert "pypa/gh-action-pypi-publish" in content
+ assert "twine upload" in content or "pypa/gh-action-pypi-publish" in content
diff --git a/tests/test_report_command.py b/tests/test_report_command.py
new file mode 100644
index 0000000..0571402
--- /dev/null
+++ b/tests/test_report_command.py
@@ -0,0 +1,47 @@
+import json
+
+from typer.testing import CliRunner
+
+from cloudcost.cli import app
+
+
+runner = CliRunner()
+
+
+def test_report_generates_html_summary(tmp_path) -> None:
+ findings_path = tmp_path / "findings.json"
+ findings_path.write_text(
+ json.dumps(
+ [
+ {
+ "policy_name": "idle_app_service_plans",
+ "provider": "azure",
+ "service_name": "App Service Plan",
+ "severity": "high",
+ "estimated_impact": 125.5,
+ "resource_id": "plan-1",
+ },
+ {
+ "policy_name": "idle_bastion",
+ "provider": "azure",
+ "service_name": "Bastion",
+ "severity": "medium",
+ "estimated_impact": 50.25,
+ "resource_id": "bastion-1",
+ },
+ ]
+ )
+ )
+ output_path = tmp_path / "report.html"
+
+ result = runner.invoke(
+ app,
+ ["report", "--input", str(findings_path), "--output", str(output_path)],
+ )
+
+ assert result.exit_code == 0, result.stdout
+ assert output_path.exists()
+ html = output_path.read_text()
+ assert "CloudCost Findings Report" in html
+ assert "125.50" in html
+ assert "$175.75" in html