diff --git a/scripts/weekly_allocation_report.py b/scripts/weekly_allocation_report.py index 4917a23..170900d 100644 --- a/scripts/weekly_allocation_report.py +++ b/scripts/weekly_allocation_report.py @@ -10,6 +10,7 @@ import math import os from pathlib import Path import re +import sys import tempfile SCHEMA = "custodian.weekly-allocation.v1" @@ -93,8 +94,19 @@ def recommendation_id(resource, namespace, workload): return "allocation-" + digest([resource, namespace, workload, "sizing-review"])[:24] +def release_lookup(index, *, namespace, name, label=None): + """Call the release-registry claim lookup. A missing index assigns nothing.""" + if not index: + return {"unit_id": None, "owner": None} + registry = Path(__file__).resolve().parents[1].parent / "release-registry" + if str(registry) not in sys.path: + sys.path.insert(0, str(registry)) + from release_registry.ownership import lookup_release + return lookup_release(index, namespace=namespace, name=name, label=label) + + def build_report(cluster, allocation, telemetry, inventory, provenance, *, as_of, - previous=None, reviews=None, max_age_hours=48): + previous=None, reviews=None, max_age_hours=48, release_index=None): now = timestamp(as_of) if cluster.get("schema_version") != "railiance.cluster-resource-observation.v1": raise ValueError("unsupported cluster schema") @@ -180,8 +192,14 @@ def build_report(cluster, allocation, telemetry, inventory, provenance, *, as_of reasons.append("owner proposal retained; incomplete evidence prevents accepted sizing recommendation") else: reasons.append("no owner-reviewed sizing decision") + labels = workload.get("labels") or {} + identity = release_lookup( + release_index, namespace=key[0], name=key[1], + label=labels.get("railiance.io/release-unit"), + ) row = { "namespace": key[0], "workload": key[1], "owner": owner.get("owner", "unknown"), + "release_unit": identity["unit_id"], "release_owner": identity["owner"], "service": owner.get("service", "unknown"), "tenant": owner.get("tenant", "unknown"), "pods": workload.get("pods"), "requests": workload.get("requests"), "limits": workload.get("limits"), "instantaneous_observed": workload.get("observed"), @@ -218,8 +236,11 @@ def build_report(cluster, allocation, telemetry, inventory, provenance, *, as_of row["comparison_reasons"][metric] = reason rows.append(row) prior_recommendation = next((r for r in (previous or {}).get("recommendations", []) if r["id"] == rid), {}) + named_owner = identity["owner"] if identity["unit_id"] else row["owner"] recommendations.append({ - "id": rid, "decision": decision, "confidence": confidence, "owner": row["owner"], + "id": rid, "namespace": key[0], "workload": key[1], + "decision": decision, "confidence": confidence, "owner": named_owner, + "release_unit": identity["unit_id"], "release_owner": identity["owner"], "reasons": reasons, "evidence_refs": evidence, "owner_proposal": review, "receiving_task": review.get("receiving_task") if review else prior_recommendation.get("receiving_task"), "previous_decision": prior_recommendation.get("decision"), @@ -287,6 +308,7 @@ def main(): parser.add_argument("--" + name, type=Path) parser.add_argument("--previous", type=Path) parser.add_argument("--reviews", type=Path) + parser.add_argument("--release-index", type=Path) parser.add_argument("--as-of") parser.add_argument("--max-age-hours", type=float, default=48) parser.add_argument("--output", type=Path) @@ -298,7 +320,7 @@ def main(): raise ValueError("unsupported executor request schema") if any(getattr(args, key) for key in ("cluster", "allocation", "telemetry", "inventory", "provenance", "previous", "reviews", "as_of", "output")): raise ValueError("request cannot be mixed with input/output overrides") - for key in ("cluster", "allocation", "telemetry", "inventory", "provenance", "previous", "reviews", "output"): + for key in ("cluster", "allocation", "telemetry", "inventory", "provenance", "previous", "reviews", "release_index", "output"): value = request.get(key) setattr(args, key, args.request.parent / value if value else None) args.as_of = request.get("as_of") @@ -309,7 +331,8 @@ def main(): inputs = {name: json.loads(getattr(args, name).read_text()) for name in ("cluster", "allocation", "telemetry", "inventory", "provenance")} report = build_report(**inputs, as_of=args.as_of, max_age_hours=args.max_age_hours, previous=json.loads(args.previous.read_text()) if args.previous else None, - reviews=json.loads(args.reviews.read_text()) if args.reviews else None) + reviews=json.loads(args.reviews.read_text()) if args.reviews else None, + release_index=json.loads(args.release_index.read_text()) if args.release_index else None) retain(args.output, report) except (OSError, ValueError, KeyError, TypeError) as exc: parser.exit(2, f"weekly allocation report refused: {exc}\n") diff --git a/tests/test_weekly_release_owner.py b/tests/test_weekly_release_owner.py new file mode 100644 index 0000000..c324ea0 --- /dev/null +++ b/tests/test_weekly_release_owner.py @@ -0,0 +1,91 @@ +"""Release-unit names on the weekly allocation recommendation.""" +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("weekly", ROOT / "scripts/weekly_allocation_report.py") +weekly = importlib.util.module_from_spec(spec) +spec.loader.exec_module(weekly) + +END = "2026-10-19T06:00:00+00:00" +RESOURCE = "resource:railiance:reef-railiance:k3s" +INDEX = { + "schema": "release-registry.index.v1", + "units": [ + { + "unit_id": "flex-auth-secrets-engine", + "declaration": { + "unit_id": "flex-auth-secrets-engine", + "owner": "access-engine", + "workloads": [ + {"kind": "Deployment", "namespace": "flex-auth", "name": "flex-auth-secrets-engine"} + ], + }, + } + ], +} + + +def _workload(namespace, name, labels=None): + item = { + "namespace": namespace, "workload": name, "pods": 1, + "requests": {"cpu_millicores": 60, "memory_bytes": 256}, + "limits": {"cpu_millicores": 1000, "memory_bytes": 1024}, + } + if labels: + item["labels"] = labels + return item + + +class WeeklyReleaseOwnerTests(unittest.TestCase): + def report(self): + cluster = { + "schema_version": "railiance.cluster-resource-observation.v1", + "resource_id": RESOURCE, "captured_at": END, + "utilization": {"workloads": [ + _workload("flex-auth", "flex-auth-secrets-engine"), + _workload("flex-auth", "flex-auth-tenant-engine"), + _workload("forgejo", "forge", {"railiance.io/release-unit": "flex-auth-secrets-engine"}), + ]}, + } + allocation = { + "schema": "custodian.allocation-reconcile.v1", "captured_at": END, + "host": {"cluster_resource_id": RESOURCE}, "count_host_and_cluster_cpus_once": True, + "workloads": [ + {"namespace": "flex-auth", "workload": "flex-auth-secrets-engine", "owner": "namespace-map"}, + {"namespace": "flex-auth", "workload": "flex-auth-tenant-engine", "owner": "namespace-map"}, + {"namespace": "forgejo", "workload": "forge", "owner": "namespace-map"}, + ], + } + return weekly.build_report( + cluster, allocation, + {"captured_at": END, "window": "7d", "step": "5m", "queries": {}}, + {"id": RESOURCE}, + {"source_observation": END, "revisions": {"collector": "a" * 40}}, + as_of=END, release_index=INDEX, + ) + + def by_name(self, report): + return {item["workload"]: item for item in report["recommendations"]} + + def test_claim_label_and_gap_name_unit_and_owner(self): + first = self.by_name(self.report()) + second = self.by_name(self.report()) + claimed = first["flex-auth-secrets-engine"] + self.assertEqual(claimed["release_unit"], "flex-auth-secrets-engine") + self.assertEqual(claimed["owner"], "access-engine") + self.assertEqual(claimed["release_owner"], "access-engine") + self.assertEqual(second["flex-auth-secrets-engine"]["release_unit"], claimed["release_unit"]) + self.assertEqual(second["flex-auth-secrets-engine"]["owner"], claimed["owner"]) + unclaimed = first["flex-auth-tenant-engine"] + self.assertIsNone(unclaimed["release_unit"]) + self.assertIsNone(unclaimed["release_owner"]) + labeled = first["forge"] + self.assertIsNone(labeled["release_unit"]) + self.assertIsNone(labeled["release_owner"]) + self.assertNotEqual(labeled["owner"], "flex-auth-secrets-engine") + + +if __name__ == "__main__": + unittest.main()