diff --git a/.gitea/workflows/runtime-distribution.yml b/.gitea/workflows/runtime-distribution.yml index 3e14fe6..f0babac 100644 --- a/.gitea/workflows/runtime-distribution.yml +++ b/.gitea/workflows/runtime-distribution.yml @@ -195,18 +195,33 @@ jobs: WEB_DIGEST="sha256:$(sha256sum runtime-output/web-index.json | cut -d' ' -f1)" python tools/release/resolve-oci-platforms.py --repository git.add-ideas.de/govoplan/runtime-api --index-digest "$API_DIGEST" --index runtime-output/api-index.json --output runtime-output/api-metadata.json python tools/release/resolve-oci-platforms.py --repository git.add-ideas.de/govoplan/runtime-web --index-digest "$WEB_DIGEST" --index runtime-output/web-index.json --output runtime-output/web-metadata.json - - name: Exercise amd64 and arm64 runtime images + - name: Resolve managed dependency platform images working-directory: govoplan env: POSTGRES_IMAGE: ${{ inputs.postgres_image }} REDIS_IMAGE: ${{ inputs.redis_image }} + run: | + docker buildx imagetools inspect "$POSTGRES_IMAGE" --raw > runtime-output/postgres-index.json + docker buildx imagetools inspect "$REDIS_IMAGE" --raw > runtime-output/redis-index.json + python tools/release/resolve-oci-platforms.py \ + --repository "${POSTGRES_IMAGE%@*}" \ + --index-digest "${POSTGRES_IMAGE##*@}" \ + --index runtime-output/postgres-index.json \ + --output runtime-output/postgres-metadata.json + python tools/release/resolve-oci-platforms.py \ + --repository "${REDIS_IMAGE%@*}" \ + --index-digest "${REDIS_IMAGE##*@}" \ + --index runtime-output/redis-index.json \ + --output runtime-output/redis-metadata.json + - name: Exercise amd64 and arm64 runtime images + working-directory: govoplan run: | for ARCH in amd64 arm64; do .runtime-build/bin/python tools/checks/runtime-image-smoke.py \ --api-metadata runtime-output/api-metadata.json \ --web-metadata runtime-output/web-metadata.json \ - --postgres-image "$POSTGRES_IMAGE" \ - --redis-image "$REDIS_IMAGE" \ + --postgres-metadata runtime-output/postgres-metadata.json \ + --redis-metadata runtime-output/redis-metadata.json \ --platform "linux/$ARCH" \ --output "runtime-output/evidence/runtime-smoke-$ARCH.json" done diff --git a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md index c9e49c9..4670c3f 100644 --- a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md +++ b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md @@ -157,6 +157,9 @@ migrations against the pinned PostgreSQL image, reach API and WebUI readiness as non-root/read-only processes, and complete a task through the pinned Redis image and packaged worker. Sanitized per-platform smoke receipts are retained as immutable release assets. +PostgreSQL and Redis indexes are resolved to untagged platform-child digests +before each smoke run. This keeps the evidence architecture-specific and +avoids retargeting one local Docker tag between incompatible platforms. The smoke also proves a bounded post-migration table contract and aborts as soon as a required container exits, rather than allowing a dead process to consume the full readiness timeout. diff --git a/tests/test_runtime_distribution_build.py b/tests/test_runtime_distribution_build.py index 083f264..dc588a1 100644 --- a/tests/test_runtime_distribution_build.py +++ b/tests/test_runtime_distribution_build.py @@ -145,6 +145,9 @@ class RuntimeDistributionBuildTests(unittest.TestCase): self.assertIn('for ARCH in amd64 arm64; do', workflow) self.assertIn("tools/checks/runtime-image-smoke.py", workflow) + self.assertIn("Resolve managed dependency platform images", workflow) + self.assertIn("--postgres-metadata", workflow) + self.assertIn("--redis-metadata", workflow) self.assertIn("runtime-smoke-amd64.json", workflow) self.assertIn("runtime-smoke-arm64.json", workflow) @@ -244,6 +247,15 @@ class RuntimeDistributionBuildTests(unittest.TestCase): "registry.example/govoplan/api@sha256:" + "1" * 64, metadata["platforms"]["linux/amd64"], ) + dependency_metadata = OCI.resolve_platforms( + index, + repository="registry.example:5000/library/postgres:16-alpine", + index_digest="sha256:" + "a" * 64, + ) + self.assertEqual( + "registry.example:5000/library/postgres@sha256:" + "2" * 64, + dependency_metadata["platforms"]["linux/arm64"], + ) with tempfile.TemporaryDirectory(prefix="govoplan-runtime-finalize-") as value: root = Path(value) diff --git a/tools/checks/runtime-image-smoke.py b/tools/checks/runtime-image-smoke.py index a02e28c..cb0831e 100644 --- a/tools/checks/runtime-image-smoke.py +++ b/tools/checks/runtime-image-smoke.py @@ -41,8 +41,8 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--api-metadata", type=Path, required=True) parser.add_argument("--web-metadata", type=Path, required=True) - parser.add_argument("--postgres-image", required=True) - parser.add_argument("--redis-image", required=True) + parser.add_argument("--postgres-metadata", type=Path, required=True) + parser.add_argument("--redis-metadata", type=Path, required=True) parser.add_argument("--platform", choices=sorted(PLATFORMS), required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--timeout-seconds", type=float, default=600.0) @@ -625,11 +625,17 @@ def main() -> int: try: api_image = platform_image(args.api_metadata, args.platform, "API") web_image = platform_image(args.web_metadata, args.platform, "Web") + postgres_image = platform_image( + args.postgres_metadata, + args.platform, + "PostgreSQL", + ) + redis_image = platform_image(args.redis_metadata, args.platform, "Redis") evidence = run_smoke( api_image=api_image, web_image=web_image, - postgres_image=args.postgres_image, - redis_image=args.redis_image, + postgres_image=postgres_image, + redis_image=redis_image, platform=args.platform, timeout=args.timeout_seconds, ) diff --git a/tools/release/resolve-oci-platforms.py b/tools/release/resolve-oci-platforms.py index 635d9cb..aaa8ac3 100644 --- a/tools/release/resolve-oci-platforms.py +++ b/tools/release/resolve-oci-platforms.py @@ -21,6 +21,12 @@ def resolve_platforms( ) -> dict[str, object]: if not repository or "@" in repository or any(value.isspace() for value in repository): raise ValueError("repository must be an unpinned OCI repository name") + last_slash = repository.rfind("/") + last_colon = repository.rfind(":") + if last_colon > last_slash: + repository = repository[:last_colon] + if not repository: + raise ValueError("repository must be an unpinned OCI repository name") if DIGEST.fullmatch(index_digest) is None: raise ValueError("index digest must be sha256:") if not isinstance(payload, dict) or not isinstance(payload.get("manifests"), list):