From 3bf2f032ae4cdb3613849e69ae2950898322614c Mon Sep 17 00:00:00 2001 From: rahimahisah17 Date: Mon, 21 Sep 2026 16:26:30 +0100 Subject: [PATCH] test: add policy SQL tests and a CI workflow Adds pytest as a dev dependency, a workflow that runs the tests on pull requests and on pushes to main, and unit tests for the aks_idle_nodepool policy using an in-memory DuckDB table. Refs #6. --- .github/workflows/tests.yml | 22 +++++ pyproject.toml | 8 ++ tests/policies/test_aks_idle_nodepool.py | 118 +++++++++++++++++++++++ uv.lock | 42 ++++++++ 4 files changed, 190 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/policies/test_aks_idle_nodepool.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..86effad --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + run: python -m pip install --upgrade pip uv + + - name: Run tests + run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 1ca6101..076c513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,3 +31,11 @@ cloudcost = "cloudcost.cli:app" [build-system] requires = ["uv_build>=0.12.17,<0.13.0"] build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/policies/test_aks_idle_nodepool.py b/tests/policies/test_aks_idle_nodepool.py new file mode 100644 index 0000000..ec11cdc --- /dev/null +++ b/tests/policies/test_aks_idle_nodepool.py @@ -0,0 +1,118 @@ +from pathlib import Path + +import duckdb +import pyarrow as pa + +from cloudcost.config.loader import PolicyConfig +from cloudcost.policies.models import Finding +from cloudcost.policies.runner import PolicyRunner + +POLICY_FILE = Path(__file__).resolve().parents[2] / "policies" / "aks_idle_nodepool.sql" + + +def make_nodepools(rows: list[dict]) -> pa.Table: + return pa.table( + { + "resource_id": pa.array([r["resource_id"] for r in rows], type=pa.string()), + "resource_name": pa.array([r["resource_name"] for r in rows], type=pa.string()), + "vm_size": pa.array([r["vm_size"] for r in rows], type=pa.string()), + "node_count": pa.array([r["node_count"] for r in rows], type=pa.int64()), + "avg_cpu_percent": pa.array([r["avg_cpu_percent"] for r in rows], type=pa.float64()), + "hourly_price_per_node": pa.array( + [r["hourly_price_per_node"] for r in rows], type=pa.float64() + ), + "lookback_days": pa.array([r["lookback_days"] for r in rows], type=pa.int64()), + } + ) + + +def nodepool(**overrides) -> dict: + row = { + "resource_id": "/subscriptions/s/resourcegroups/rg/providers/vmss/pool1", + "resource_name": "cluster1/pool1", + "vm_size": "Standard_D2s_v3", + "node_count": 3, + "avg_cpu_percent": 4.0, + "hourly_price_per_node": 0.10, + "lookback_days": 7, + } + row.update(overrides) + return row + + +def run_policy(tmp_path: Path, rows: list[dict], policy_file: Path = POLICY_FILE) -> list[Finding]: + db_path = tmp_path / "costs.duckdb" + conn = duckdb.connect(str(db_path)) + try: + conn.register("nodepools", make_nodepools(rows)) + conn.execute("CREATE TABLE fact_aks_idle_nodepool AS SELECT * FROM nodepools") + finally: + conn.close() + + policy = PolicyConfig( + name="aks-idle-nodepool", + type="sql", + query_file=str(policy_file), + severity="medium", + ) + return PolicyRunner(str(db_path)).run_policy(policy) + + +def test_flags_nodepool_below_cpu_threshold(tmp_path: Path) -> None: + findings = run_policy(tmp_path, [nodepool()]) + + assert len(findings) == 1 + finding = findings[0] + assert finding.provider == "azure" + assert finding.service_name == "AKS Node Pool" + assert finding.severity == "medium" + assert finding.resource_id == "/subscriptions/s/resourcegroups/rg/providers/vmss/pool1" + assert finding.evidence["evidence_reason"] == ( + "3 x Standard_D2s_v3 nodes averaging 4.0% CPU over 7 days" + ) + + +def test_monthly_cost_is_nodes_times_hourly_price_times_730(tmp_path: Path) -> None: + findings = run_policy(tmp_path, [nodepool(node_count=3, hourly_price_per_node=0.10)]) + + assert findings[0].estimated_impact == 219.0 + + +def test_monthly_cost_is_rounded_to_two_decimals(tmp_path: Path) -> None: + findings = run_policy(tmp_path, [nodepool(node_count=2, hourly_price_per_node=0.0777)]) + + assert findings[0].estimated_impact == 113.44 + + +def test_cpu_at_the_threshold_is_not_flagged(tmp_path: Path) -> None: + findings = run_policy(tmp_path, [nodepool(avg_cpu_percent=10.0)]) + + assert findings == [] + + +def test_busy_nodepool_is_not_flagged(tmp_path: Path) -> None: + findings = run_policy(tmp_path, [nodepool(avg_cpu_percent=45.0)]) + + assert findings == [] + + +def test_nodepool_without_a_price_is_not_flagged(tmp_path: Path) -> None: + findings = run_policy(tmp_path, [nodepool(hourly_price_per_node=0.0)]) + + assert findings == [] + + +def test_only_idle_nodepools_are_returned_from_a_mixed_table(tmp_path: Path) -> None: + rows = [ + nodepool(resource_id="idle", avg_cpu_percent=2.0), + nodepool(resource_id="busy", avg_cpu_percent=60.0), + nodepool(resource_id="unpriced", avg_cpu_percent=1.0, hourly_price_per_node=0.0), + ] + + findings = run_policy(tmp_path, rows) + + assert [f.resource_id for f in findings] == ["idle"] + + +def test_empty_table_returns_no_findings(tmp_path: Path) -> None: + assert run_policy(tmp_path, []) == [] diff --git a/uv.lock b/uv.lock index 07c1a9f..11c8fd8 100644 --- a/uv.lock +++ b/uv.lock @@ -506,6 +506,11 @@ dependencies = [ { name = "typer" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "adbc-driver-postgresql", specifier = ">=1.12.0" }, @@ -525,6 +530,9 @@ requires-dist = [ { name = "typer", specifier = ">=0.27.2" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "colorama" version = "0.4.6" @@ -916,6 +924,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "isodate" version = "0.7.2" @@ -1305,6 +1322,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/be/3ae887e84c38c4dd1c549cd5825adc93f48069a94fe1cb8ac52a9da16f15/platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", size = 24992, upload-time = "2026-09-19T01:18:45.825Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.5.4" @@ -1621,6 +1647,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"