diff --git a/scripts/capacity-scan.py b/scripts/capacity-scan.py index 6f3acf5..709d471 100644 --- a/scripts/capacity-scan.py +++ b/scripts/capacity-scan.py @@ -43,3 +43,38 @@ def parse_table(markdown, required_cols): rows.append(dict(zip(headers, cells))) return rows return [] + + +def compute_rollup(node_rows, workload_rows): + """Per node: physical totals, summed allocations, RAM headroom %, and an + oversubscribed flag. Workloads on unknown nodes are ignored.""" + nodes = {} + for r in node_rows: + nodes[r["node"]] = { + "cores": int(r["cores"]), + "ram_gb": float(r["ram_gb"]), + "disk_gb": float(r["disk_gb"]), + "alloc_cores": 0, + "alloc_ram_mb": 0, + "alloc_disk_gb": 0.0, + } + for w in workload_rows: + node = nodes.get(w["node"]) + if node is None: + continue + node["alloc_cores"] += int(w["cores"]) + node["alloc_ram_mb"] += int(w["ram_mb"]) + node["alloc_disk_gb"] += float(w["disk_gb"]) + for node in nodes.values(): + node["alloc_ram_gb"] = round(node.pop("alloc_ram_mb") / 1024, 1) + node["ram_headroom_pct"] = ( + round(100 * (node["ram_gb"] - node["alloc_ram_gb"]) / node["ram_gb"]) + if node["ram_gb"] + else 0 + ) + node["oversubscribed"] = ( + node["alloc_cores"] > node["cores"] + or node["alloc_ram_gb"] > node["ram_gb"] + or node["alloc_disk_gb"] > node["disk_gb"] + ) + return nodes diff --git a/tests/test_capacity_scan.py b/tests/test_capacity_scan.py index b0cfa8c..069bdf2 100644 --- a/tests/test_capacity_scan.py +++ b/tests/test_capacity_scan.py @@ -26,3 +26,35 @@ trailing text def test_parse_table_returns_empty_when_header_absent(): assert cs.parse_table("no tables here", ["node", "cores"]) == [] + + +def test_compute_rollup_sums_allocations_and_flags_headroom(): + node_rows = [{"node": "pve0", "cores": "20", "ram_gb": "64", "disk_gb": "4000"}] + workload_rows = [ + {"workload": "dns1", "node": "pve0", "cores": "1", "ram_mb": "512", "disk_gb": "10"}, + {"workload": "forgejo", "node": "pve0", "cores": "4", "ram_mb": "8192", "disk_gb": "100"}, + ] + nodes = cs.compute_rollup(node_rows, workload_rows) + pve0 = nodes["pve0"] + assert pve0["alloc_cores"] == 5 + assert pve0["alloc_ram_gb"] == 8.5 # (512 + 8192) / 1024 + assert pve0["alloc_disk_gb"] == 110.0 + assert pve0["ram_headroom_pct"] == 87 # round(100 * (64 - 8.5) / 64) + assert pve0["oversubscribed"] is False + + +def test_compute_rollup_flags_oversubscription(): + node_rows = [{"node": "tiny", "cores": "2", "ram_gb": "4", "disk_gb": "50"}] + workload_rows = [ + {"workload": "hog", "node": "tiny", "cores": "4", "ram_mb": "1024", "disk_gb": "10"}, + ] + nodes = cs.compute_rollup(node_rows, workload_rows) + assert nodes["tiny"]["oversubscribed"] is True # 4 cores > 2 + + +def test_compute_rollup_ignores_workloads_on_unknown_nodes(): + nodes = cs.compute_rollup( + [{"node": "pve0", "cores": "20", "ram_gb": "64", "disk_gb": "4000"}], + [{"workload": "ghost", "node": "nope", "cores": "1", "ram_mb": "512", "disk_gb": "10"}], + ) + assert nodes["pve0"]["alloc_cores"] == 0