154 lines
7 KiB
Python
154 lines
7 KiB
Python
|
|
#!/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())
|