From 82b2024727e5f4aab082932ffe352f492386c1a8 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 22 Oct 2026 01:07:54 +0200 Subject: [PATCH] Pass the composed release index into the weekly assessment. The in-cluster runner and the request file both load config/release-index.json, so a claimed workload's recommendation names the unit id and the declaration owner. --- config/release-index.json | 488 ++++++++++++++++++++++ config/weekly-allocation-request.json | 1 + scripts/weekly_allocation_incluster.py | 15 +- scripts/weekly_allocation_report.py | 36 +- tests/test_weekly_allocation_incluster.py | 43 +- 5 files changed, 573 insertions(+), 10 deletions(-) create mode 100644 config/release-index.json diff --git a/config/release-index.json b/config/release-index.json new file mode 100644 index 00000000..8b936994 --- /dev/null +++ b/config/release-index.json @@ -0,0 +1,488 @@ +{ + "schema": "release-registry.index.v1", + "units": [ + { + "declaration": { + "composition": { + "member_repos": [ + { + "deployables": [ + "flex-auth-secrets-engine" + ], + "repo": "access-engine", + "role": "application source" + } + ], + "purpose": "Decides secrets-engine policy for one flex-auth consumer." + }, + "contract_version": "1.0.0", + "criticality": "high", + "data_classification": "restricted", + "kind": "release-unit", + "owner": "access-engine", + "pins": { + "component": { + "name": "ocm.railiance.dev/flex-auth-secrets-engine", + "version": "0.2.0" + }, + "resources": [ + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:05a03a8790c2210c48ea92391441c77ddf640d0cd32f5ec09838f5393171fcbd" + }, + "name": "flex-auth", + "type": "ociImage", + "version": "0.2.0" + } + ], + "sources": [ + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "0344b2a962e4ed977ea622b08cf20bf49d25b603" + }, + "name": "access-engine", + "type": "git", + "version": "0.2.0" + } + ] + }, + "rollback_contract": { + "order": [ + "helm-rollback" + ] + }, + "rollout_contract": { + "default_mode": "helm-upgrade-install" + }, + "smoke_contract": { + "required": [ + "http-ready" + ] + }, + "unit_id": "flex-auth-secrets-engine", + "workloads": [ + { + "kind": "Deployment", + "name": "flex-auth-secrets-engine", + "namespace": "flex-auth" + } + ] + }, + "owner": "access-engine", + "path": "/home/worsch/access-engine/registry/release-units/flex-auth-secrets-engine.yaml", + "source": "dir:/home/worsch/access-engine", + "unit_id": "flex-auth-secrets-engine" + }, + { + "declaration": { + "composition": { + "member_repos": [ + { + "deployables": [ + "forge", + "forgejo-runner" + ], + "repo": "railiance-apps", + "role": "helm values and runner manifest" + }, + { + "deployables": [ + "forge", + "forgejo-runner" + ], + "repo": "railiance-forge", + "role": "forge operating contracts" + } + ], + "purpose": "Serves Forgejo git, packages, and the in-cluster Actions runner as one rollout." + }, + "contract_version": "1.0.0", + "criticality": "critical", + "data_classification": "restricted", + "kind": "release-unit", + "owner": "railiance-apps", + "pins": { + "component": { + "name": "ocm.railiance.dev/forgejo", + "version": "11.0.3" + }, + "resources": [ + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba" + }, + "name": "forgejo", + "type": "ociImage", + "version": "11.0.3" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b" + }, + "name": "forgejo-runner", + "type": "ociImage", + "version": "11.0.3" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce" + }, + "name": "docker", + "type": "ociImage", + "version": "11.0.3" + } + ], + "sources": [ + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "9ecb123451717a4d70ec50d490a228c30ea5ba88" + }, + "name": "railiance-apps", + "type": "git", + "version": "11.0.3" + }, + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "2afdff68b1e22c48c4e7d5f20457f6cc420cb25c" + }, + "name": "railiance-forge", + "type": "git", + "version": "11.0.3" + } + ] + }, + "rollback_contract": { + "order": [ + "helm-rollback" + ] + }, + "rollout_contract": { + "default_mode": "helm-upgrade-install" + }, + "smoke_contract": { + "required": [ + "http-ready" + ] + }, + "unit_id": "forgejo", + "workloads": [ + { + "kind": "Deployment", + "name": "forge", + "namespace": "forgejo" + }, + { + "kind": "Deployment", + "name": "forgejo-runner", + "namespace": "forgejo" + } + ] + }, + "owner": "railiance-apps", + "path": "/home/worsch/railiance-apps/registry/release-units/forgejo.yaml", + "source": "dir:/home/worsch/railiance-apps", + "unit_id": "forgejo" + }, + { + "declaration": { + "composition": { + "member_repos": [ + { + "deployables": [ + "knative" + ], + "repo": "rail-knative", + "role": "substrate declaration" + }, + { + "deployables": [ + "knative" + ], + "repo": "railiance-cluster", + "role": "cluster install" + } + ], + "purpose": "Serves Knative workloads through the shared Serving and Kourier substrate.", + "upstream_components": [ + { + "name": "knative-serving", + "source": "gcr.io/knative-releases", + "version": "1.22.0" + }, + { + "name": "net-kourier", + "source": "gcr.io/knative-releases", + "version": "1.22.0" + } + ] + }, + "contract_version": "1.0.0", + "criticality": "critical", + "data_classification": "internal", + "kind": "release-unit", + "owner": "rail-knative", + "pins": { + "component": { + "name": "ocm.railiance.dev/knative", + "version": "1.22.0" + }, + "resources": [ + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:8f7e9df2642a8ba715ebc55b62abee17b0984b6b6af0c6ec00dabaadf5f8cccb" + }, + "name": "activator", + "type": "ociImage", + "version": "1.22.0" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:ec66d97244395b57a67923d4a8c5a95f0264805f1fdb2ee0cdb49ec0f3bd7855" + }, + "name": "autoscaler", + "type": "ociImage", + "version": "1.22.0" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:6833f2c83e9f356c274ddb2de40e5471ad2b1c08882c9bd11f5afa240e86d574" + }, + "name": "controller", + "type": "ociImage", + "version": "1.22.0" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:c505888f7ee34ccadc1b9a6877d6d49691f633ea28cf8cbe03fcfa6d3afcd584" + }, + "name": "webhook", + "type": "ociImage", + "version": "1.22.0" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:a2e8f568a4bf68ef6391bc3afea956d11a8698f6a22c4be50844b1da4f8bcc74" + }, + "name": "net-kourier-controller", + "type": "ociImage", + "version": "1.22.0" + }, + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:1c2b79776c6e3b38e8b0113b825e6a599f9bfc08d680c199d80bf8964856c529" + }, + "name": "kourier-gateway", + "type": "ociImage", + "version": "1.22.0" + } + ], + "sources": [ + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "affe67a4bc0fbdde9460edd8208cb8f895a23bbd" + }, + "name": "rail-knative", + "type": "git", + "version": "1.22.0" + }, + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "a6742635824a7568cdae4aebdd743a6457c20e33" + }, + "name": "railiance-cluster", + "type": "git", + "version": "1.22.0" + } + ] + }, + "rollback_contract": { + "order": [ + "restore-previous-overlay" + ] + }, + "rollout_contract": { + "default_mode": "kustomize-overlay" + }, + "smoke_contract": { + "required": [ + "deployment-available" + ] + }, + "unit_id": "knative", + "workloads": [ + { + "kind": "Deployment", + "name": "activator", + "namespace": "knative-serving" + }, + { + "kind": "Deployment", + "name": "autoscaler", + "namespace": "knative-serving" + }, + { + "kind": "Deployment", + "name": "controller", + "namespace": "knative-serving" + }, + { + "kind": "Deployment", + "name": "webhook", + "namespace": "knative-serving" + }, + { + "kind": "Deployment", + "name": "net-kourier-controller", + "namespace": "knative-serving" + }, + { + "kind": "Deployment", + "name": "3scale-kourier-gateway", + "namespace": "kourier-system" + } + ] + }, + "owner": "rail-knative", + "path": "/home/worsch/rail-knative/registry/release-units/knative.yaml", + "source": "dir:/home/worsch/rail-knative", + "unit_id": "knative" + }, + { + "declaration": { + "composition": { + "member_repos": [ + { + "deployables": [ + "vergabe" + ], + "repo": "railiance-apps", + "role": "rollout owner" + }, + { + "deployables": [ + "vergabe-teilnahme" + ], + "repo": "vergabe-teilnahme", + "role": "application source" + } + ], + "purpose": "Serves the Vergabe participation application for one company." + }, + "contract_version": "1.0.0", + "criticality": "high", + "data_classification": "confidential", + "depends_on": [ + { + "readiness": "available", + "unit_id": "vergabe-db", + "version": "1.0.0" + } + ], + "kind": "release-unit", + "owner": "railiance-apps", + "pins": { + "component": { + "name": "ocm.railiance.dev/vergabe", + "version": "0.2.1" + }, + "references": [ + { + "componentName": "vergabe-db", + "name": "database", + "version": "1.0.0" + } + ], + "resources": [ + { + "digest": { + "hashAlgorithm": "SHA-256", + "normalisationAlgorithm": "ociArtifactDigest/v1", + "value": "sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" + }, + "name": "vergabe-teilnahme", + "type": "ociImage", + "version": "0.2.1" + } + ], + "sources": [ + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "e9d2b5f39a6fe1c4accb5c54e3ba0b37d05b23ee" + }, + "name": "railiance-apps", + "type": "git", + "version": "0.2.1" + }, + { + "digest": { + "hashAlgorithm": "SHA-1", + "normalisationAlgorithm": "gitCommit/v1", + "value": "dca6683a8ab7d4aa0de2a537924f73318b199f60" + }, + "name": "vergabe-teilnahme", + "type": "git", + "version": "0.2.1" + } + ] + }, + "rollback_contract": { + "order": [ + "helm-rollback" + ] + }, + "rollout_contract": { + "default_mode": "helm-upgrade-install" + }, + "smoke_contract": { + "required": [ + "http-ready" + ] + }, + "unit_id": "vergabe", + "workloads": [ + { + "kind": "Deployment", + "name": "vergabe-teilnahme", + "namespace": "vergabe-demo-company" + } + ] + }, + "owner": "railiance-apps", + "path": "/home/worsch/railiance-apps/registry/release-units/vergabe.yaml", + "source": "dir:/home/worsch/railiance-apps", + "unit_id": "vergabe" + } + ] +} diff --git a/config/weekly-allocation-request.json b/config/weekly-allocation-request.json index b342f3c3..8cdf611e 100644 --- a/config/weekly-allocation-request.json +++ b/config/weekly-allocation-request.json @@ -8,6 +8,7 @@ "provenance": null, "previous": null, "reviews": null, + "release_index": "release-index.json", "as_of": null, "output": null } diff --git a/scripts/weekly_allocation_incluster.py b/scripts/weekly_allocation_incluster.py index be72ea4b..24fbd5ba 100644 --- a/scripts/weekly_allocation_incluster.py +++ b/scripts/weekly_allocation_incluster.py @@ -239,6 +239,16 @@ def run(args, *, now: datetime, get, query) -> dict: "weekly_allocation_incluster": Path(__file__).resolve(), "queries": args.queries, "ownership": args.ownership, "inventory": args.inventory, } + release_index_path = getattr(args, "release_index", None) + if release_index_path is None: + raise ValueError("release index must be bound") + release_index_path = Path(release_index_path) + sources["release_index"] = release_index_path + release_index = json.loads(release_index_path.read_text()) + if not isinstance(release_index, dict) or ( + "units" not in release_index and "findings" not in release_index + ): + raise ValueError("release index is not a composed index or coverage report") weekly = load(sources["weekly_allocation_report"], "weekly_allocation_report") reconcile = load(sources["reconcile_railiance_allocation"], "reconcile_railiance_allocation") observer = load(sources["observe_cluster_resources"], "observe_cluster_resources") @@ -280,7 +290,8 @@ def run(args, *, now: datetime, get, query) -> dict: report = weekly.build_report( **inputs, as_of=iso(now), max_age_hours=args.max_age_hours, previous=json.loads(previous_path.read_text()) if previous_path else None, - reviews=json.loads(args.reviews.read_text()) if args.reviews and args.reviews.exists() else None) + reviews=json.loads(args.reviews.read_text()) if args.reviews and args.reviews.exists() else None, + release_index=release_index) weekly.retain(target, report) failed = sorted(n for n, q in telemetry["queries"].items() if q["response"].get("status") != "success") return {**receipt, "status": "retained", "report_sha256": sha256(target), @@ -302,6 +313,8 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--ownership", type=Path, default=env("ASSESSMENT_OWNERSHIP") and Path(env("ASSESSMENT_OWNERSHIP"))) p.add_argument("--inventory", type=Path, default=env("ASSESSMENT_INVENTORY") and Path(env("ASSESSMENT_INVENTORY"))) p.add_argument("--reviews", type=Path, default=env("ASSESSMENT_REVIEWS") and Path(env("ASSESSMENT_REVIEWS"))) + p.add_argument("--release-index", type=Path, default=Path(env( + "ASSESSMENT_RELEASE_INDEX", str(HERE.parent / "config/release-index.json")))) p.add_argument("--prometheus-url", default=env("PROMETHEUS_URL", "http://telemetry-prometheus.telemetry.svc:9090")) p.add_argument("--hub-url", default=env("STATE_HUB_URL"), help="State Hub API; unset disables delivery") p.add_argument("--executor", default=env("ASSESSMENT_EXECUTOR", "unspecified")) diff --git a/scripts/weekly_allocation_report.py b/scripts/weekly_allocation_report.py index 170900d1..60145c4e 100644 --- a/scripts/weekly_allocation_report.py +++ b/scripts/weekly_allocation_report.py @@ -10,7 +10,6 @@ import math import os from pathlib import Path import re -import sys import tempfile SCHEMA = "custodian.weekly-allocation.v1" @@ -95,14 +94,35 @@ def recommendation_id(resource, namespace, workload): def release_lookup(index, *, namespace, name, label=None): - """Call the release-registry claim lookup. A missing index assigns nothing.""" - if not index: + """Unit id and declaration owner for one workload. + + A composed index is the authority: one claim on namespace and name. + ``label`` is ``railiance.io/release-unit`` and is not used. A coverage + report has no declaration owner, so findings alone assign neither. + The same rule lives in release_registry.ownership.lookup_release. This + copy stays in the assessment so the slim in-cluster image can run it. + """ + del label + matches = [] + for unit in (index or {}).get("units") or []: + if not isinstance(unit, dict): + continue + declaration = unit.get("declaration") or {} + if not isinstance(declaration, dict): + continue + unit_id = str(unit.get("unit_id") or declaration.get("unit_id") or "") + owner = declaration.get("owner") + if not unit_id or not isinstance(owner, str) or not owner: + continue + for claim in declaration.get("workloads") or []: + if not isinstance(claim, dict): + continue + if str(claim.get("namespace") or "") == namespace and str(claim.get("name") or "") == name: + matches.append((unit_id, owner)) + if len(matches) != 1: 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) + unit_id, owner = matches[0] + return {"unit_id": unit_id, "owner": owner} def build_report(cluster, allocation, telemetry, inventory, provenance, *, as_of, diff --git a/tests/test_weekly_allocation_incluster.py b/tests/test_weekly_allocation_incluster.py index a7116516..f1391052 100644 --- a/tests/test_weekly_allocation_incluster.py +++ b/tests/test_weekly_allocation_incluster.py @@ -51,7 +51,9 @@ class InClusterRunnerTests(unittest.TestCase): self.args = argparse.Namespace( data_dir=self.data, source_dir=ROOT / "scripts", observer=base / "observer.py", queries=ROOT / "config/weekly-allocation-queries.json", ownership=base / "ownership.json", - inventory=base / "inventory.json", reviews=None, prometheus_url="http://prom.test:9090", + inventory=base / "inventory.json", reviews=None, + release_index=ROOT / "config/release-index.json", + prometheus_url="http://prom.test:9090", executor="test", max_age_hours=48.0) def tearDown(self): @@ -89,6 +91,45 @@ class InClusterRunnerTests(unittest.TestCase): self.assertEqual(incluster.missed_weeks("2026-W52", "2027-W01"), ["2026-W53"]) # 2026 has 53 ISO weeks self.assertEqual(incluster.missed_weeks("2026-W53", "2027-W01"), []) + def test_claimed_workload_names_unit_and_declaration_owner(self): + observer = (Path(self.tmp.name) / "observer.py").read_text() + (Path(self.tmp.name) / "observer.py").write_text(observer.replace( + '"workload": p["metadata"]["labels"]["app"]', + '"workload": p["metadata"]["labels"]["app"], "labels": p["metadata"].get("labels") or {}', + )) + self.get = self._claimed_get + (Path(self.tmp.name) / "ownership.json").write_text(json.dumps({ + "host": {"cluster_resource_id": RESOURCE}, + "namespaces": {"flex-auth": {"owner": "namespace-map", "service": "flex-auth", "tenant": "unknown"}}})) + receipt = self.run_at(datetime(2026, 10, 19, 6, 0, 30, tzinfo=timezone.utc)) + report = json.loads((self.data / "reports/2026-W43.json").read_text()) + named = {item["workload"]: item for item in report["recommendations"]} + claimed = named["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.assertIsNone(named["unclaimed"]["release_unit"]) + self.assertIsNone(named["labeled"]["release_unit"]) + self.assertIsNone(named["labeled"]["release_owner"]) + self.assertEqual(named["labeled"]["owner"], "namespace-map") + self.assertEqual(receipt["status"], "retained") + + def _claimed_get(self, path): + self.reads.append(path) + if path.startswith("/apis/metrics.k8s.io"): + if path.endswith("/nodes"): + return {"items": [{"metadata": {"name": "railiance01"}, "usage": {"cpu": "1500000000n", "memory": "2048Ki"}}]} + return {"items": []} + if path == "/api/v1/pods": + labeled = pod("flex-auth", "labeled-1", "labeled") + labeled["metadata"]["labels"]["railiance.io/release-unit"] = "flex-auth-secrets-engine" + return {"items": [ + pod("flex-auth", "flex-auth-secrets-engine-1", "flex-auth-secrets-engine"), + pod("flex-auth", "other-1", "unclaimed"), + labeled, + ]} + return {"items": []} + def test_reads_only_collector_paths_and_retains_report(self): receipt = self.run_at(datetime(2026, 10, 19, 6, 0, 30, tzinfo=timezone.utc)) self.assertEqual(receipt["status"], "retained")