From 9fcca5718d417fc06b31e063acb454a367ba6ec9 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Thu, 17 Sep 2026 05:50:54 +0300 Subject: [PATCH] fix: smoke platform children without overwriting index digests --- delivery/ci/publish_mcp_artifacts.py | 40 +++++++++++++++++-- tests/test_mcp_publication.py | 58 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/delivery/ci/publish_mcp_artifacts.py b/delivery/ci/publish_mcp_artifacts.py index e2694f6..a41ca50 100644 --- a/delivery/ci/publish_mcp_artifacts.py +++ b/delivery/ci/publish_mcp_artifacts.py @@ -348,10 +348,12 @@ def smoke(self, image, runtime_sha, manifest): """ require(re.fullmatch(re.escape(IMAGE) + r"@sha256:[0-9a-f]{64}", image), "image_identity") bounded_command(attestation_command("oci://" + image, runtime_sha), seconds=90) + images = platform_images(image) with tempfile.TemporaryDirectory(prefix="v8std-anonymous-") as directory: env = anonymous_environment(directory) for platform in ("linux/amd64", "linux/arm64"): - bounded_command(["docker", "pull", "--platform", platform, image], env=env, seconds=180) + platform_image = images[platform] + bounded_command(["docker", "pull", "--platform", platform, platform_image], env=env, seconds=180) name = "v8std-ci-default-" + uuid.uuid4().hex primary = None try: @@ -359,7 +361,7 @@ def smoke(self, image, runtime_sha, manifest): "--platform", platform, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--init", "--pids-limit", "128", "--memory", "1024m", "--cpus", "2", "--tmpfs", "/tmp:size=16m", "--tmpfs", "/var/lib/v8std-mcp:rw,size=256m,uid=10001,gid=10001,mode=0700", - "-p", "127.0.0.1::8000", image, "--transport", "streamable-http", "--host", "0.0.0.0", + "-p", "127.0.0.1::8000", platform_image, "--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000", "--refresh-seconds", "0"], env=env, seconds=30) inspected = json.loads(bounded_command(["docker", "inspect", name], env=env))[0] require(inspected["Config"]["Labels"].get("org.opencontainers.image.revision") == runtime_sha, @@ -402,11 +404,12 @@ def local_smoke(self, image, runtime_sha, site_image, site_sha, manifest): links share the unchanged Compose v8std.localhost address. """ root = Path(__file__).resolve().parents[2] + runtime_images, site_images = platform_images(image), platform_images(site_image) with tempfile.TemporaryDirectory(prefix="v8std-pair-proof-") as directory: for platform in ("linux/amd64", "linux/arm64"): project = "v8std-ci-pair-" + uuid.uuid4().hex[:16] env = {**anonymous_environment(directory), "DOCKER_DEFAULT_PLATFORM": platform, - "V8STD_SITE_IMAGE": site_image, "V8STD_MCP_IMAGE": image, + "V8STD_SITE_IMAGE": site_images[platform], "V8STD_MCP_IMAGE": runtime_images[platform], "V8STD_SITE_PORT": "18765", "V8STD_MCP_PORT": "18766", "V8STD_SITE_PREFIX": "/", "V8STD_MCP_SITE_URL": "http://v8std.localhost:18765/"} compose = ["docker", "compose", "-p", project, "-f", str(root / "delivery/local/compose.yaml"), "--profile", "mcp"] @@ -797,6 +800,37 @@ def registry_manifest(image, reference): return raw +def platform_images(image): + """Select children of the verified index without reusing its local digest. + + Docker's classic image store cannot pull two architectures under the same + index digest ("cannot overwrite digest"). Child digests remain bound to the + signed index by its checked content hash and are distinct local references. + """ + namespace, separator, digest = image.partition("@") + require(separator and namespace in {IMAGE, SITE_IMAGE} + and re.fullmatch(r"sha256:[0-9a-f]{64}", digest), "image_identity") + raw = registry_manifest(namespace, digest) + require(raw is not None, "published_image_required") + members = strict_json(raw).get("manifests") + require(isinstance(members, list), "platform_descriptor") + result = {} + for architecture in ("amd64", "arm64"): + selected = [item for item in members if isinstance(item, dict) + and isinstance(item.get("platform"), dict) + and item["platform"].get("os") == "linux" + and item["platform"].get("architecture") == architecture + and item["platform"].get("variant", "") in ({"", "v8"} if architecture == "arm64" else {""})] + require(len(selected) == 1, "platform_descriptor") + child = selected[0] + require(child.get("mediaType") in { + "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json"} + and isinstance(child.get("digest"), str) and re.fullmatch(r"sha256:[0-9a-f]{64}", child["digest"]), + "platform_descriptor") + result["linux/" + architecture] = namespace + "@" + child["digest"] + return result + + def registry_tags(): status, raw = registry_request(IMAGE, "tags/list?n=1000") if status == 404: diff --git a/tests/test_mcp_publication.py b/tests/test_mcp_publication.py index a14fc68..157514d 100644 --- a/tests/test_mcp_publication.py +++ b/tests/test_mcp_publication.py @@ -730,6 +730,33 @@ class TransportBoundaryTests(unittest.TestCase): def setUp(self): self.p = importlib.import_module("delivery.ci.publish_mcp_artifacts") + @staticmethod + def platform_references(image): + namespace = image.split("@")[0] + return {"linux/amd64": namespace + "@sha256:" + "1" * 64, + "linux/arm64": namespace + "@sha256:" + "2" * 64} + + def test_platform_images_select_children_bound_to_exact_index_digest(self): + members = [{"mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:" + digit * 64, + "platform": {"os": "linux", "architecture": arch}} + for arch, digit in (("amd64", "1"), ("arm64", "2"))] + members[1]["platform"]["variant"] = "v8" + valid = {"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": members} + def resolve(value, digest=None): + raw = fixture.json_bytes(value) + reference = self.p.IMAGE + "@sha256:" + (digest or self.p.hashlib.sha256(raw).hexdigest()) + with patch.object(self.p, "registry_request", return_value=(200, raw)): + return self.p.platform_images(reference) + self.assertEqual(resolve(valid), self.platform_references(self.p.IMAGE)) + with self.assertRaisesRegex(self.p.PublicationError, "registry_digest"): + resolve(valid, "a" * 64) + for replacement in (None, members[:1], members + [members[0]], + [members[0], {**members[1], "digest": "invalid"}], + [members[0], {**members[1], "mediaType": "invalid"}]): + with self.subTest(members=replacement), self.assertRaisesRegex(self.p.PublicationError, "platform_descriptor"): + resolve({**valid, "manifests": replacement}) + def test_registry_without_tagged_images_has_no_reusable_runtime(self): with patch.object(self.p, "registry_request", return_value=(200, b'{"name":"zeegin/v8std-mcp","tags":null}')): self.assertEqual(self.p.registry_tags(), []) @@ -778,8 +805,8 @@ def command(argv, **kwargs): project = argv[3] if "pull" == argv[-1]: projects.append(project) - self.assertEqual(env["V8STD_MCP_IMAGE"], self.p.IMAGE + "@sha256:" + "a" * 64) - self.assertEqual(env["V8STD_SITE_IMAGE"], self.p.SITE_IMAGE + "@sha256:" + "c" * 64) + self.assertEqual(env["V8STD_MCP_IMAGE"], self.platform_references(self.p.IMAGE)[platform]) + self.assertEqual(env["V8STD_SITE_IMAGE"], self.platform_references(self.p.SITE_IMAGE)[platform]) effects.append((platform, "pull")) elif "up" in argv: self.assertIn("--no-build", argv) @@ -814,6 +841,7 @@ def smoke(url, record, deadline): now[0] += 390 raise ValueError("not ready") with patch.dict(os.environ, {}, clear=True), patch.object(self.p, "bounded_command", side_effect=command), \ + patch.object(self.p, "platform_images", side_effect=self.platform_references), \ patch.object(self.p, "runtime_smoke", side_effect=smoke), \ patch.object(self.p.time, "monotonic", side_effect=lambda: now[0]): if name == "valid": @@ -995,6 +1023,7 @@ def command(argv, **kwargs): return b"{}" with patch.dict(os.environ, {"DOCKER_AUTH_CONFIG": "synthetic", "DOCKER_CONTEXT": "synthetic-remote", "REGISTRY_AUTH_FILE": "/synthetic/auth"}, clear=True), \ + patch.object(self.p, "platform_images", side_effect=self.platform_references), \ patch.object(self.p, "bounded_command", side_effect=command): with self.assertRaisesRegex(self.p.PublicationError, "command_timeout") as raised: self.p.CITransport().smoke(self.p.IMAGE + "@sha256:" + "a" * 64, "b" * 40, {}) @@ -1006,6 +1035,31 @@ def command(argv, **kwargs): self.assertFalse(name in kwargs["env"], "anonymous environment retains key: " + name) self.assertFalse(Path(kwargs["env"]["DOCKER_CONFIG"]).exists()) + def test_default_source_uses_distinct_child_digests_for_pull_and_run(self): + pulled, running = {}, {} + image = self.p.IMAGE + "@sha256:" + "a" * 64 + def command(argv, **kwargs): + if argv[:2] == ["docker", "pull"]: + platform = argv[3] + self.assertNotIn(argv[-1], pulled.values()) + pulled[platform] = argv[-1] + elif argv[:2] == ["docker", "run"]: + platform = argv[argv.index("--platform") + 1] + self.assertEqual(pulled[platform], self.platform_references(image)[platform]) + self.assertIn(pulled[platform], argv) + running[argv[argv.index("--name") + 1]] = platform + elif argv[:2] == ["docker", "inspect"]: + self.assertIn(argv[-1], running) + return json.dumps([{"Config": {"Labels": {"org.opencontainers.image.revision": "b" * 40}}, + "NetworkSettings": {"Ports": {"8000/tcp": [{"HostPort": "12345"}]}}}]).encode() + return b"" + with patch.object(self.p, "platform_images", side_effect=self.platform_references), \ + patch.object(self.p, "bounded_command", side_effect=command), \ + patch.object(self.p, "runtime_smoke") as smoke: + self.p.CITransport().smoke(image, "b" * 40, fixture.snapshot_fixture()[1]) + self.assertEqual(set(pulled), {"linux/amd64", "linux/arm64"}) + self.assertEqual(smoke.call_count, 2) + def test_release_exact_queue_identity_capacity_failure_and_malformed_platform_fail_closed(self): context = dict(event="push", repository="zeegin/v8std", ref="refs/heads/main", sha="c" * 40, main_sha="c" * 40, run_id=1, run_number=2, attempt=1,