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}
+
+ + + + + + + + + + + + {table_body} + +
PolicyProviderServiceSeverityImpact ($)
+ + +""" + + 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