the-custodian/scripts/reconcile_railiance_allocation.py

154 lines
7 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""CUST-WP-0071-T01: join live cluster allocation with owner/service/tenant."""
from __future__ import annotations
import argparse
import importlib.util
import json
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[1]
CLUSTER_OBS = Path.home() / "railiance-cluster" / "tools" / "observe_cluster_resources.py"
HEADROOM = Path.home() / "state-hub" / "scripts" / "release_headroom_preflight.py"
OWNERS = ROOT / "docs" / "evidence" / "namespace-ownership.yaml"
def load_module(path: Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def join(observation: dict[str, Any], owners: dict[str, Any]) -> dict[str, Any]:
mapping = owners.get("namespaces") or {}
rows = []
unknown = []
zero_request = []
for workload in observation.get("utilization", {}).get("workloads") or []:
ns = workload["namespace"]
meta = mapping.get(ns, {"owner": "unknown", "service": "unknown", "tenant": "unknown"})
row = {
"namespace": ns,
"workload": workload["workload"],
"pods": workload["pods"],
"cpu_request_m": workload["requests"]["cpu_millicores"],
"memory_request_bytes": workload["requests"]["memory_bytes"],
"owner": meta.get("owner", "unknown"),
"service": meta.get("service", "unknown"),
"tenant": meta.get("tenant", "unknown"),
}
rows.append(row)
if ns not in mapping:
unknown.append(ns)
if workload["requests"]["cpu_millicores"] == 0:
zero_request.append(f"{ns}/{workload['workload']}")
pending = observation.get("allocation", {}).get("pending_unscheduled") or {}
alloc = observation.get("allocation") or {}
capacity = observation.get("capacity", {}).get("totals") or {}
residual = (alloc.get("residual_capacity") or {}).get("cpu_millicores", 0)
requested = (alloc.get("denominators") or {}).get("cpu_requested_millicores", 0)
return {
"schema": "custodian.allocation-reconcile.v1",
"workplan_id": "CUST-WP-0071-T01",
"captured_at": observation.get("captured_at"),
"cluster_observation_schema": observation.get("schema_version"),
"count_host_and_cluster_cpus_once": True,
"host": owners.get("host"),
"capacity_cpu_m": capacity.get("cpu_millicores"),
"scheduled_request_cpu_m": requested,
"pending_unscheduled_cpu_m": pending.get("cpu_millicores", 0),
"residual_cpu_m": residual,
"not_a_scheduling_guarantee": True,
"workloads": sorted(rows, key=lambda r: (-r["cpu_request_m"], r["namespace"], r["workload"])),
"unknown_namespaces": sorted(set(unknown)),
"zero_request_workloads": sorted(zero_request),
"pending_unscheduled": pending.get("pods") or [],
"measurement_gaps": observation.get("measurement_gaps") or [],
"state_hub_preflight_observation": observation.get("state_hub_preflight_observation"),
"signal_notes": [
"Host resource:hosteurope:railiance01 and cluster resource:railiance:reef-railiance:k3s are the same 4 vCPU; do not add them.",
"Prometheus namespace_cpu:kube_pod_container_resource_requests:sum omitted Forgejo 100m on 2026-09-11; Kubernetes API is the scheduler-effective source.",
"node-exporter was disabled; host CPU usage is not a reservation and is not treated as spare capacity.",
"Zero-request pods are demand, not zero. Missing metrics are listed as gaps, not zeros.",
"STATE-WP-0091 preflight consumes state_hub_preflight_observation; residual millicores are not admission.",
],
}
def render_markdown(report: dict[str, Any], preflight: dict[str, Any] | None) -> str:
lines = [
f"# Railiance allocation reconcile — {report['captured_at']}",
"",
"CUST-WP-0071-T01. Scheduler-effective requests from the Kubernetes API",
"via `railiance-cluster` observe (RCLUSTER-WP-0014 / RAIL-BS-WP-0014).",
"Host and cluster are the same 4 vCPU and are counted once.",
"",
f"- Capacity: {report['capacity_cpu_m']}m",
f"- Scheduled requests: {report['scheduled_request_cpu_m']}m",
f"- Pending unscheduled: {report['pending_unscheduled_cpu_m']}m",
f"- Residual (not a guarantee): {report['residual_cpu_m']}m",
f"- Unknown namespaces: {', '.join(report['unknown_namespaces']) or 'none'}",
f"- Zero-request workloads: {len(report['zero_request_workloads'])}",
"",
"| Namespace | Workload | Owner | Tenant | CPU request (m) | Pods |",
"|---|---|---|---|---:|---:|",
]
for row in report["workloads"]:
lines.append(
f"| {row['namespace']} | {row['workload']} | {row['owner']} | {row['tenant']} | {row['cpu_request_m']} | {row['pods']} |"
)
lines += ["", "## STATE-WP-0091 preflight", ""]
if preflight:
lines.append(f"- ok: `{preflight.get('ok')}`")
lines.append(f"- remaining_cpu_m: {preflight.get('remaining_cpu_m')}")
for reason in preflight.get("reasons") or []:
lines.append(f"- refuse: {reason}")
for note in preflight.get("notes") or []:
lines.append(f"- note: {note}")
lines += ["", "## Signal notes", ""]
for note in report["signal_notes"]:
lines.append(f"- {note}")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--observation", type=Path, help="Existing cluster observation JSON")
parser.add_argument("--kubectl", default="kubectl")
parser.add_argument("--output-dir", type=Path, default=ROOT / "docs" / "evidence")
args = parser.parse_args()
cluster = load_module(CLUSTER_OBS, "cluster_resources")
if args.observation:
observation = json.loads(args.observation.read_text())
else:
observation = cluster.collect(args.kubectl)
owners = yaml.safe_load(OWNERS.read_text())
report = join(observation, owners)
preflight = None
if observation.get("state_hub_preflight_observation"):
headroom = load_module(HEADROOM, "release_headroom")
preflight = headroom.evaluate(observation["state_hub_preflight_observation"])
report["state_hub_preflight"] = preflight
stamp = datetime.now(UTC).strftime("%Y-%m-%d")
out_json = args.output_dir / f"{stamp}-allocation-reconcile.json"
out_md = args.output_dir / f"{stamp}-allocation-reconcile.md"
args.output_dir.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
out_md.write_text(render_markdown(report, preflight), encoding="utf-8")
print(out_json)
print(out_md)
return 0
if __name__ == "__main__":
raise SystemExit(main())