#!/usr/bin/env python3 """Resolve an exact desired source tuple into a canonical digest-only BOM.""" from __future__ import annotations import argparse import re from pathlib import Path from typing import Any from validate_serviceification_bom import ( RESOLVED_SCHEMA_VERSION_V10, ValidationFailure, canonical_json_bytes, canonical_json_sha256, frontend_source_tuple, load_bom, publication_receipt_roles, validate_bom, validate_frontend_publication_receipt, validate_l0_engine_publication, validate_publication_receipt, ) def _load_publication( path: Path, desired: dict[str, Any], label: str, ) -> dict[str, Any]: publication = load_bom(path) errors = validate_publication_receipt(publication, label) fields = ("oci_repository", "source_repository", "source_commit") + (("digest",) if label == "services.mcp_worker" else ()) for field in fields: if publication.get(field) != desired.get(field): errors.append( f"{label}.{field} disagrees with desired tuple: " f"got {publication.get(field)!r}, want {desired.get(field)!r}" ) if errors: raise ValidationFailure("; ".join(errors)) return {key: item for key, item in publication.items() if key != "verification_receipt_digest"} def _load_frontend_publication( path: Path, desired: dict[str, Any], frontend_publisher_commit: str, ) -> dict[str, Any]: publication = load_bom(path) errors = validate_frontend_publication_receipt(publication, "frontend.publication") source_tuple_sha256 = canonical_json_sha256(frontend_source_tuple(desired)) expected = { "oci_repository": desired["oci_repository"], "source_repository": desired["l2_repository"], "source_commit": frontend_publisher_commit, "source_tuple_sha256": source_tuple_sha256, } for field, wanted in expected.items(): if publication.get(field) != wanted: errors.append( f"frontend.publication.{field} disagrees with desired tuple: " f"got {publication.get(field)!r}, want {wanted!r}" ) if errors: raise ValidationFailure("; ".join(errors)) return {key: item for key, item in publication.items() if key != "verification_receipt_digest"} def _load_l0_engine_publication( path: Path, desired: dict[str, Any], ) -> dict[str, Any]: publication = load_bom(path) errors = validate_l0_engine_publication(publication, "l0_engine", receipt=True) if publication.get("oci_repository") != desired.get("oci_repository"): errors.append( "l0_engine.oci_repository disagrees with desired tuple: " f"got {publication.get('oci_repository')!r}, " f"want {desired.get('oci_repository')!r}" ) release = publication.get("release") resolved_version = release.get("version") if isinstance(release, dict) else None if resolved_version != desired.get("version"): errors.append( "l0_engine.release.version disagrees with desired tuple: " f"got {resolved_version!r}, want {desired.get('version')!r}" ) if errors: raise ValidationFailure("; ".join(errors)) return {key: item for key, item in publication.items() if key != "verification_receipt_digest"} def resolve( desired: dict[str, Any], receipts_dir: Path, frontend_publisher_commit: str, ) -> dict[str, Any]: errors = validate_bom(desired, require_desired=True) if not re.fullmatch(r"[0-9a-f]{40}", frontend_publisher_commit): errors.append("frontend_publisher_commit must be a full lowercase commit SHA") if errors: raise ValidationFailure("; ".join(errors)) required_roles = publication_receipt_roles(desired) expected_files = {f"{role}.json" for role in required_roles} actual_files = {path.name for path in receipts_dir.glob("*.json")} if actual_files != expected_files: missing = sorted(expected_files - actual_files) unknown = sorted(actual_files - expected_files) details: list[str] = [] if missing: details.append(f"missing publication receipts: {', '.join(missing)}") if unknown: details.append(f"unknown publication receipts: {', '.join(unknown)}") raise ValidationFailure("; ".join(details)) services = { role: _load_publication( receipts_dir / f"{role}.json", desired["services"][role], f"services.{role}" ) for role in sorted(desired["services"]) } l0_engine = _load_l0_engine_publication( receipts_dir / "l0_engine.json", desired["l0_engine"], ) frontend_request = desired["frontend"] frontend_publication = _load_frontend_publication( receipts_dir / "frontend.json", frontend_request, frontend_publisher_commit, ) installation = dict(desired["app_installation"]) source_tuple = frontend_source_tuple(frontend_request) resolved = { "schema_version": RESOLVED_SCHEMA_VERSION_V10, "desired_bom_sha256": canonical_json_sha256(desired), "l0_engine": l0_engine, "services": services, "frontend": { "publication": frontend_publication, "source_tuple": source_tuple, "routing_mode": frontend_request["routing_mode"], }, "app_installation": installation, "l2_metadata": dict(desired["l2_metadata"]), "execution": dict(desired["execution"]), "runtime_worker_roles": [dict(role) for role in desired["runtime_worker_roles"]], "catalog_consumers": dict(desired["catalog_consumers"]), } errors = validate_bom(resolved, require_deployable=True) if errors: raise ValidationFailure("; ".join(errors)) return resolved def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--desired-bom", default="release/serviceification-d3-bom.json") parser.add_argument("--receipts-dir", required=True) parser.add_argument("--frontend-publisher-commit", required=True) parser.add_argument("--output", required=True) args = parser.parse_args() desired_path = Path(args.desired_bom).resolve() try: desired = load_bom(desired_path) resolved = resolve( desired, Path(args.receipts_dir).resolve(), args.frontend_publisher_commit, ) except (OSError, ValidationFailure) as exc: print(f"cannot resolve serviceification BOM: {exc}") return 1 output = Path(args.output).resolve() output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(canonical_json_bytes(resolved)) print(f"serviceification BOM resolved: {output}") return 0 if __name__ == "__main__": raise SystemExit(main())