diff --git a/README.md b/README.md index 40f773639..07539d3a7 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ main and differ from the producer only in the closed qualification-source set. The existing v1 model still requires runner and producer to be the same commit. V2 receipts retain both identities, each actual dependency image, the owned resource scope, body observations and verified cleanup. The four Compose cells +retain engine target IDs, platform manifests and config digests distinctly, and run on amd64 hosts; their arm64 server cells use the pinned QEMU helper. These receipts do not select alias promotion or a GitHub Release. diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 36d69e53a..83d924292 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -16,8 +16,8 @@ # checks/CLI transport is bound to reviewed bytes. A source change requires an # explicit model update and countercontrol review before gaining coverage. QUALIFICATION_CLEANUP_SOURCES = { - "scripts/ci/native_qualification.py": "2b7309458989723e0635549a8a1c176a18297d4d057eda612474e10e311c5980", - "scripts/ci/native_alias_evidence.py": "38241ccbb0b20a7566a43df3fec2ef0afffa0e33bb7c442f4bdce39b92fe2673", + "scripts/ci/native_qualification.py": "65916e73b7f0c6431c612f411e27f9429a2c37dbab287d1f17b840dee0f8d185", + "scripts/ci/native_alias_evidence.py": "b39aa4c96b7b5642b4fbb800dad0d02f0a393f7638a95220dc0c439a939ad200", "scripts/ci/native_alias_transport.py": "053af14689c7c69d6620860390801fc900a331126f32a4f68fac6eb0e6593fef", } diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py index 3acd42666..f3c8f73e7 100644 --- a/scripts/ci/native_alias_evidence.py +++ b/scripts/ci/native_alias_evidence.py @@ -27,6 +27,7 @@ RECEIPT_SCHEMA = "durable-workflow.native-qualification-receipt.v1" MANIFEST_SCHEMA_V2 = "durable-workflow.native-alias-promotion-evidence.v2" RECEIPT_SCHEMA_V2 = "durable-workflow.native-qualification-receipt.v2" +LOCAL_IMAGE_KEYS = {"local_image_id", "local_manifest_digest", "config_digest_source"} SUBJECT_PATH = "scripts/ci/native-qualification-subject.json" RUNNER_REF = "refs/heads/feat/server-native32-qualification-runner" RUNNER_PATHS = { @@ -459,7 +460,7 @@ def bind_receipt(value: dict[str, Any], role: str, selected: dict[str, Any], run: dict[str, Any], job: dict[str, Any], release: dict[str, Any], platform: str, profile: str | None, subject: dict[str, Any], runner: dict[str,str] | None = None, binding: dict[str,Any] | None = None) -> None: - extra = {"runner_ref", "subject_blob", "resource_scope", "dependencies"} if runner else set() + extra = {"runner_ref", "subject_blob", "resource_scope", "dependencies"} | LOCAL_IMAGE_KEYS if runner else set() value = closed(value, RECEIPT_KEYS | extra, "receipt_shape") require(value["schema"] == (RECEIPT_SCHEMA_V2 if runner else RECEIPT_SCHEMA) and value["kind"] == role and value["eligible"] is True, "receipt_not_eligible") @@ -484,6 +485,7 @@ def bind_receipt(value: dict[str, Any], role: str, selected: dict[str, Any], require(binding is not None and binding["release"] == release, "receipt_binding_missing") observed_scope = resource_scope(role, run["id"], run["run_attempt"], platform, profile) require(value["resource_scope"] == observed_scope, "receipt_resource_scope") + bind_local_image(value, subject, release["index_digest"]) bind_dependencies(value["dependencies"], role, platform, binding) cleanup = closed(value["cleanup"], {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"}, @@ -508,6 +510,18 @@ def resource_scope(role: str, run: int, attempt: int, platform: str, profile: st f"published-compose-{run}-{attempt}-smoke-{platform.split('/')[1]}-{profile}") +def bind_local_image(value: dict[str, Any], subject: dict[str, Any], root_digest: str) -> None: + require(matching(value["local_image_id"], DIGEST), "receipt_local_image_identity") + if value["config_digest_source"] == "engine_id": + require(value["local_image_id"] == subject["image_config_digest"] + and value["local_manifest_digest"] is None, "receipt_local_image_identity") + else: + require(value["config_digest_source"] == "manifest_descriptor" + and value["local_image_id"] in {root_digest, subject["child_manifest_digest"]} + and value["local_manifest_digest"] == subject["child_manifest_digest"], + "receipt_local_image_identity") + + def bind_dependencies(value: Any, role: str, platform: str, subject: dict[str,Any]) -> None: if role == "corpus": closed(value, set(), "receipt_dependency_shape") @@ -519,11 +533,12 @@ def bind_dependencies(value: Any, role: str, platform: str, subject: dict[str,An continue keys = {"requested_image", "child_manifest_digest", "image_config_digest", "actual_image_config_digest", "container_image_architecture", "container_image_os"} - observed = closed(value[name], keys | ({"container_id"} if name == "mysql" else set()), + observed = closed(value[name], keys | LOCAL_IMAGE_KEYS | ({"container_id"} if name == "mysql" else set()), "receipt_dependency_identity") expected = {**subject[name], "actual_image_config_digest": subject[name]["image_config_digest"], "container_image_architecture": "amd64", "container_image_os": "linux"} require(all(literal(observed[key], item) for key,item in expected.items()), "receipt_dependency_binding") + bind_local_image(observed, subject[name], subject[name]["child_manifest_digest"]) if name == "mysql": require(type(observed["container_id"]) is str and re.fullmatch(r"[0-9a-f]{64}", observed["container_id"]), "receipt_database_container") diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py index eee4be13d..eeba2a814 100644 --- a/scripts/ci/native_qualification.py +++ b/scripts/ci/native_qualification.py @@ -17,7 +17,7 @@ DIGEST, FINAL_TAG, GHCR_IMAGE, JSON_LIMIT, PLATFORMS, RECEIPT_SCHEMA, RECEIPT_SCHEMA_V2, REPOSITORY, REPOSITORY_ID, SHA, WORKFLOWS, SUBJECT_PATH, RUNNER_REF, qualification_subject, runner_subject, - resource_scope, bind_dependencies, + resource_scope, bind_dependencies, LOCAL_IMAGE_KEYS, Refusal, canonical, closed, literal, matching, positive, require, strict_json) from native_alias_transport import Commands, GitHubRegistryProvider @@ -100,43 +100,125 @@ def admit(provider: GitHubRegistryProvider, role: str, image: str) -> dict[str, return {"runner": runner, "subject": subject} -def dependency_image(commands: Commands, subject: dict[str,str], repositories: tuple[str,...]) -> dict[str,Any]: - config = inspect(commands, "image", subject["image_config_digest"]) - require(config.get("Id") == subject["image_config_digest"] and config.get("Architecture") == "amd64" - and config.get("Os") == "linux" and type(config.get("RepoDigests")) is list +MANIFEST_TYPES = {"application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"} +DEPENDENCY_REPOSITORIES = { + "mysql": ("mysql:8.0", ("mysql", "docker.io/library/mysql")), + "qemu": ("docker.io/tonistiigi/binfmt", ("tonistiigi/binfmt", "docker.io/tonistiigi/binfmt")), +} + + +def require_manifest(value: Any, digest: str) -> None: + require(type(value) is dict and value.get("digest") == digest + and type(value.get("mediaType")) is str and value["mediaType"] in MANIFEST_TYPES, + "actual_platform_manifest") + + +def image_manifest(provider: GitHubRegistryProvider, subject: dict[str, str], requested: str) -> Any: + repository = requested.split("@", 1)[0] + if repository == GHCR_IMAGE: + return provider.registry_json(repository, subject["child_manifest_digest"]) + # The shared promotion transport remains Server-only. These two exact + # qualification dependencies use an independently closed read-only path. + require(repository in {item[0] for item in DEPENDENCY_REPOSITORIES.values()} + and matching(subject.get("child_manifest_digest"), DIGEST) + and requested == repository + "@" + subject["child_manifest_digest"], "dependency_registry_subject") + return strict_json(provider.commands.run( + ["docker", "buildx", "imagetools", "inspect", "--raw", requested]), JSON_LIMIT) + + +def local_image(provider: GitHubRegistryProvider, subject: dict[str, str], requested: str, + platform: str, root_digest: str, container: dict[str, Any] | None = None + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Keep the engine target ID distinct from its manifest's config digest.""" + commands = provider.commands + if container is None: + config = inspect(commands, "image", requested) + local_id = config.get("Id") + else: + local_id = container.get("Image") + require(matching(local_id, DIGEST), "actual_container_image") + require(local_id in {subject["image_config_digest"], root_digest, subject["child_manifest_digest"]}, + "actual_container_image") + if local_id == subject["image_config_digest"]: + config = inspect(commands, "image", local_id) + else: + require_manifest(container.get("ImageManifestDescriptor"), subject["child_manifest_digest"]) + config = inspect(commands, "image", local_id, platform=platform) + require(matching(local_id, DIGEST), "actual_local_image") + if local_id == subject["image_config_digest"]: + require(config.get("Id") == local_id, "actual_local_image") + identity = {"local_image_id": local_id, "local_manifest_digest": None, + "config_digest_source": "engine_id"} + else: + require(local_id in {root_digest, subject["child_manifest_digest"]} + and config.get("Id") == subject["child_manifest_digest"], "actual_local_image") + require_manifest(config.get("Descriptor"), subject["child_manifest_digest"]) + # The platform descriptor is engine-observed. Its immutable manifest + # supplies the config linkage; an engine target ID is never relabeled. + manifest = image_manifest(provider, subject, requested) + require(type(manifest) is dict and manifest.get("schemaVersion") == 2 + and type(manifest.get("mediaType")) is str and manifest["mediaType"] in MANIFEST_TYPES + and type(manifest.get("config")) is dict + and manifest["config"].get("digest") == subject["image_config_digest"], + "actual_manifest_config") + identity = {"local_image_id": local_id, "local_manifest_digest": subject["child_manifest_digest"], + "config_digest_source": "manifest_descriptor"} + require(config.get("Architecture") == platform.split("/")[1] and config.get("Os") == "linux", + "actual_container_platform") + return config, identity + + +def dependency_image(provider: GitHubRegistryProvider, subject: dict[str,str], role: str, + container: dict[str, Any] | None = None) -> dict[str,Any]: + require(type(role) is str and role in DEPENDENCY_REPOSITORIES, "dependency_registry_subject") + repository, repositories = DEPENDENCY_REPOSITORIES[role] + require(matching(subject.get("child_manifest_digest"), DIGEST) + and subject.get("requested_image") == repository + "@" + subject["child_manifest_digest"], + "dependency_registry_subject") + config, identity = local_image(provider, subject, subject["requested_image"], "linux/amd64", + subject["child_manifest_digest"], container) + require(type(config.get("RepoDigests")) is list and any(repository + "@" + subject["child_manifest_digest"] in config["RepoDigests"] for repository in repositories), "actual_dependency_image") - return {**subject, "actual_image_config_digest": config["Id"], + return {**subject, **identity, "actual_image_config_digest": subject["image_config_digest"], "container_image_architecture": config["Architecture"], "container_image_os": config["Os"]} -def observe_dependencies(commands: Commands, role: str, platform: str, scope: str, subject: dict[str,Any]) -> dict[str,Any]: +def observe_dependencies(provider: GitHubRegistryProvider, role: str, platform: str, scope: str, subject: dict[str,Any]) -> dict[str,Any]: if role == "corpus": return {} + commands = provider.commands containers = lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", LABEL_FILTER + COMPOSE_OWNER + "=" + scope, "--filter", "label=com.docker.compose.service=mysql"], OBJECT_ID) require(len(containers) == 1, "actual_database_inventory") identity = containers.pop() database = inspect(commands, "container", identity) labels = database.get("Config", {}).get("Labels", {}) - require(database.get("Id") == identity and database.get("Image") == subject["mysql"]["image_config_digest"] + require(database.get("Id") == identity + and matching(database.get("Image"), DIGEST) + and database["Image"] in {subject["mysql"]["image_config_digest"], subject["mysql"]["child_manifest_digest"]} and database.get("Config", {}).get("Image") == subject["mysql"]["requested_image"] and labels.get(COMPOSE_OWNER) == scope and labels.get("com.docker.compose.service") == "mysql", "actual_database_subject") - mysql = {**dependency_image(commands, subject["mysql"], ("mysql", "docker.io/library/mysql")), + mysql = {**dependency_image(provider, subject["mysql"], "mysql", database), "container_id": identity} - qemu = (dependency_image(commands, subject["qemu"], ("tonistiigi/binfmt", "docker.io/tonistiigi/binfmt")) + qemu = (dependency_image(provider, subject["qemu"], "qemu") if platform == "linux/arm64" else None) return {"mysql": mysql, "qemu": qemu} -def inspect(commands: Commands, resource: str, identity: str) -> dict[str, Any]: +def inspect(commands: Commands, resource: str, identity: str, *, platform: str | None = None) -> dict[str, Any]: if resource not in ("container", "image", "network", "volume"): raise Refusal("docker_resource_kind") # Leading CLI options, whitespace, separators and paths are never identities. - if not re.fullmatch(r"(?:sha256:[0-9a-f]{64}|[A-Za-z0-9][A-Za-z0-9_.-]{0,180})", identity): + pinned_image = (resource == "image" and re.fullmatch( + r"(?:ghcr\.io/durable-workflow/server|mysql:8\.0|docker\.io/tonistiigi/binfmt)@sha256:[0-9a-f]{64}", identity)) + if not pinned_image and not re.fullmatch(r"(?:sha256:[0-9a-f]{64}|[A-Za-z0-9][A-Za-z0-9_.-]{0,180})", identity): raise Refusal("docker_resource_identity") - value = strict_json(commands.run(["docker", resource, "inspect", identity]), JSON_LIMIT) + require(platform is None or resource == "image" and platform in PLATFORMS, "docker_inspect_platform") + args = ["docker", resource, "inspect"] + (["--platform", platform] if platform else []) + [identity] + value = strict_json(commands.run(args), JSON_LIMIT) require(type(value) is list and len(value) == 1 and type(value[0]) is dict, "docker_inspect_shape") return value[0] @@ -156,12 +238,16 @@ def observe_image(provider: GitHubRegistryProvider, role: str, image: str, index = image.split("@", 1)[1] subject = provider.index_subjects(GHCR_IMAGE, index, source, tag)[platform] instance = inspect(provider.commands, "container", container) - require(instance.get("Image") == subject["image_config_digest"], "actual_container_image") - config = inspect(provider.commands, "image", subject["image_config_digest"]) - require(config.get("Id") == subject["image_config_digest"] - and config.get("Architecture") == platform.split("/")[1] - and config.get("Os") == "linux" and type(config.get("Config")) is dict, - "actual_container_platform") + local = {} + if admission: + config, local = local_image(provider, subject, image, platform, index, instance) + else: + require(instance.get("Image") == subject["image_config_digest"], "actual_container_image") + config = inspect(provider.commands, "image", subject["image_config_digest"]) + require(config.get("Id") == subject["image_config_digest"] + and config.get("Architecture") == platform.split("/")[1] + and config.get("Os") == "linux", "actual_container_platform") + require(type(config.get("Config")) is dict, "actual_container_platform") labels = config["Config"].get("Labels") require(type(labels) is dict and labels.get("org.opencontainers.image.revision") == source and labels.get("dev.durable-workflow.release.tag") == tag, "actual_container_source") @@ -172,9 +258,10 @@ def observe_image(provider: GitHubRegistryProvider, role: str, image: str, require(os.environ.get("DW_PUBLISHED_COMPOSE_PROJECT" if role == "compose" else "RESOURCE_SCOPE") == observed_scope, "qualification_resource_scope") extra = {"runner_ref": admission["runner"]["ref"], "subject_blob": admission["runner"]["subject_blob"], - "resource_scope": observed_scope, "dependencies": observe_dependencies(provider.commands, role, platform, observed_scope, admission["subject"])} + "resource_scope": observed_scope, **local, + "dependencies": observe_dependencies(provider, role, platform, observed_scope, admission["subject"])} return {"eligible": True, "requested_image": image, "index_digest": index, - "platform": platform, **subject, "actual_image_config_digest": instance["Image"], + "platform": platform, **subject, "actual_image_config_digest": subject["image_config_digest"], "container_image_architecture": config["Architecture"], "container_image_os": config["Os"], "image_source_commit": source, "image_release_tag": tag, **extra} @@ -330,7 +417,7 @@ def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: "actual_image_config_digest", "container_image_architecture", "container_image_os", "image_source_commit", "image_release_tag"} if admission: - image_keys |= {"runner_ref", "subject_blob", "resource_scope", "dependencies"} + image_keys |= {"runner_ref", "subject_blob", "resource_scope", "dependencies"} | LOCAL_IMAGE_KEYS require(identity.get("runner_ref") == admission["runner"]["ref"] and identity.get("subject_blob") == admission["runner"]["subject_blob"] and identity.get("resource_scope") == resource_scope(role,run_id,attempt,platform,profile), diff --git a/tests/Unit/Support/native_alias_evidence_test.py b/tests/Unit/Support/native_alias_evidence_test.py index 733ea2f2d..54ec44e39 100644 --- a/tests/Unit/Support/native_alias_evidence_test.py +++ b/tests/Unit/Support/native_alias_evidence_test.py @@ -219,6 +219,8 @@ def __init__(self): record.update(schema=E.RECEIPT_SCHEMA_V2,runner_commit=RUNNER,runner_ref=E.RUNNER_REF, subject_blob=self.binding_blob, resource_scope=E.resource_scope(record["kind"], record["run_id"],1,record["platform"],record["profile"]), + local_image_id=record["image_config_digest"],local_manifest_digest=None, + config_digest_source="engine_id", dependencies=self.dependencies(record["kind"],record["platform"])) self.set_artifact(identity,record) @@ -229,6 +231,8 @@ def dependencies(self, role, platform): for name in ("mysql","qemu"): result[name] = ({**self.binding[name], "actual_image_config_digest": self.binding[name]["image_config_digest"], + "local_image_id":self.binding[name]["image_config_digest"],"local_manifest_digest":None, + "config_digest_source":"engine_id", "container_image_architecture":"amd64","container_image_os":"linux"} if name == "mysql" or platform == "linux/arm64" else None) result["mysql"]["container_id"] = "f" * 64 @@ -259,6 +263,24 @@ def workflow_blob(self, commit, path): class RunnerEvidenceTests(unittest.TestCase): + def test_containerd_receipts_bind_target_manifest_and_config_as_distinct_identities(self): + provider = RunnerProvider() + for identity in list(provider.archives): + record = E.archive_receipt(provider.archives[identity]) + record.update(local_image_id=INDEX, local_manifest_digest=record["child_manifest_digest"], + config_digest_source="manifest_descriptor") + for dependency in record["dependencies"].values(): + if dependency is not None: + dependency.update(local_image_id=dependency["child_manifest_digest"], + local_manifest_digest=dependency["child_manifest_digest"], + config_digest_source="manifest_descriptor") + provider.set_artifact(identity, record) + proof = E.verify(provider.selected, provider) + self.assertEqual(5, len(proof["qualified_artifact_ids"])) + provider.mutate_receipt(501, lambda record: record.update(local_manifest_digest=INDEX)) + with self.assertRaisesRegex(E.Refusal, "receipt_local_image_identity"): + E.verify(provider.selected, provider) + def test_reviewed_runner_can_qualify_the_unchanged_published_producer(self): provider = RunnerProvider() proof = E.verify(provider.selected,provider) @@ -297,6 +319,8 @@ def test_every_new_runner_and_dependency_field_is_authoritative(self): lambda p:p.mutate_receipt(504,lambda r:r["dependencies"].update(qemu=None)), lambda p:p.mutate_receipt(502,lambda r:r["dependencies"].update(qemu=p.dependencies("compose","linux/arm64")["qemu"])), lambda p:p.job_sets[302][0]["steps"].pop(), + lambda p:p.mutate_receipt(501,lambda r:r.update(local_image_id=[])), + lambda p:p.mutate_receipt(502,lambda r:r["dependencies"]["mysql"].update(config_digest_source="unknown")), ] for i,change in enumerate(mutations): provider=RunnerProvider();change(provider) diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index e2fb45462..d3428c9f6 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -280,13 +280,84 @@ def run(self, args, *unused, **kwargs): value = {"Id":identity,"Architecture":"amd64","Os":"linux","Config":{"Labels":{ "org.opencontainers.image.revision":SOURCE,"dev.durable-workflow.release.tag":"3.2.0"}}} for name, repo in (("mysql","mysql"),("qemu","tonistiigi/binfmt")): - if identity == self.provider.binding[name]["image_config_digest"]: + if identity in (self.provider.binding[name]["image_config_digest"],self.provider.binding[name]["requested_image"]): + value["Id"] = self.provider.binding[name]["image_config_digest"] value["RepoDigests"] = [repo+"@"+(INDEX if name == "qemu" and self.wrong_qemu_digest else self.provider.binding[name]["child_manifest_digest"])] else: raise AssertionError("unexpected command") return json.dumps([value]).encode() +class ContainerdCommands(RunnerCommands): + """Docker 29 target IDs, with an independently identified platform manifest.""" + platform = "linux/amd64" + server_id = INDEX + image_mutation = None + container_mutation = None + + def run(self, args, *more, **kwargs): + identity = args[-1] + if args[:5] == ["docker", "buildx", "imagetools", "inspect", "--raw"]: + self.reads.append(args) + subject = next(value for name, value in self.provider.binding.items() + if name in ("mysql", "qemu") and value["requested_image"] == identity) + return json.dumps({"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": {"digest": subject["image_config_digest"]}}).encode() + if args[:3] == ["docker", "container", "inspect"]: + self.reads.append(args) + if identity == CID: + subject = self.provider.subjects[self.platform] + value = {"Image": self.server_id, "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": subject["child_manifest_digest"]}} + if self.container_mutation: + self.container_mutation(value) + else: + value = {"Id": "f" * 64, "Image": self.provider.binding["mysql"]["child_manifest_digest"], + "Config": {"Image": self.provider.binding["mysql"]["requested_image"], "Labels": { + Q.COMPOSE_OWNER: self.database_owner, "com.docker.compose.service": "mysql"}}} + value["ImageManifestDescriptor"] = {"mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": self.provider.binding["mysql"]["child_manifest_digest"]} + return json.dumps([value]).encode() + if args[:3] != ["docker", "image", "inspect"]: + return super().run(args, *more, **kwargs) + self.reads.append(args) + if identity == self.server_id: + subject = self.provider.subjects[self.platform] + architecture = self.platform.split("/")[1] + config = {"Labels": {"org.opencontainers.image.revision": SOURCE, + "dev.durable-workflow.release.tag": "3.2.0"}} + repository = E.GHCR_IMAGE + else: + name = next(name for name in ("mysql", "qemu") if identity in { + self.provider.binding[name]["child_manifest_digest"], self.provider.binding[name]["requested_image"]}) + subject = self.provider.binding[name] + architecture, config = "amd64", {} + repository = "mysql" if name == "mysql" else "tonistiigi/binfmt" + value = {"Id": subject["child_manifest_digest"], "Descriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": subject["child_manifest_digest"]}, + "Architecture": architecture, "Os": "linux", "Config": config, + "RepoDigests": [repository + "@" + subject["child_manifest_digest"]]} + if self.image_mutation: + self.image_mutation(value) + return json.dumps([value]).encode() + + +def containerd_provider(): + provider = RunnerProvider() + provider.commands = ContainerdCommands(provider) + provider.index_subjects = lambda *args: copy.deepcopy(provider.subjects) + + def registry_image(repository, digest): + subjects = list(provider.subjects.values()) + [provider.binding[name] for name in ("mysql", "qemu")] + subject = next(item for item in subjects if item["child_manifest_digest"] == digest) + return {"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": {"digest": subject["image_config_digest"]}} + + provider.registry_json = registry_image + return provider + + class RunnerProducerTests(unittest.TestCase): def context(self, provider): return {**CONTEXT,"GITHUB_SHA":RUNNER,"GITHUB_REF":E.RUNNER_REF, @@ -318,11 +389,11 @@ def test_actual_producer_v2_receipt_is_consumable_without_restamping_the_image_s provider.selected["runner"],provider.binding) self.assertEqual(record["image_source_commit"],SOURCE) self.assertEqual(record["runner_commit"],RUNNER) - dependencies=Q.observe_dependencies(provider.commands,"compose","linux/arm64",context["DW_PUBLISHED_COMPOSE_PROJECT"],provider.binding) + dependencies=Q.observe_dependencies(provider,"compose","linux/arm64",context["DW_PUBLISHED_COMPOSE_PROJECT"],provider.binding) E.bind_dependencies(dependencies,"compose","linux/arm64",provider.binding) provider.commands.wrong_qemu_digest=True with self.assertRaisesRegex(E.Refusal,"actual_dependency_image"): - Q.observe_dependencies(provider.commands,"compose","linux/arm64",context["DW_PUBLISHED_COMPOSE_PROJECT"],provider.binding) + Q.observe_dependencies(provider,"compose","linux/arm64",context["DW_PUBLISHED_COMPOSE_PROJECT"],provider.binding) provider.commands.wrong_qemu_digest=False for attribute,value,reason in (("database_config",INDEX,"actual_database_subject"), ("database_owner","foreign","actual_database_subject")): @@ -346,6 +417,94 @@ def test_manual_runner_has_no_builder_and_admits_before_dependency_steps(self): self.assertIn(binding["mysql"]["requested_image"].removeprefix("mysql:"),compose) self.assertIn(binding["qemu"]["requested_image"],compose) + def test_containerd_index_and_child_ids_produce_consumable_platform_bound_receipts(self): + for platform in E.PLATFORMS: + for root_is_index in (True, False): + provider = containerd_provider() + provider.runs[303]["status"] = "in_progress" + for job in provider.job_sets[303]: + job["status"] = "in_progress" + commands = provider.commands + commands.platform = platform + commands.server_id = INDEX if root_is_index else provider.subjects[platform]["child_manifest_digest"] + context = self.context(provider) + context["DW_PUBLISHED_COMPOSE_PROJECT"] = E.resource_scope("compose", 303, 1, platform, "local") + commands.database_owner = context["DW_PUBLISHED_COMPOSE_PROJECT"] + with self.subTest(platform=platform, root_is_index=root_is_index), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / E.SUBJECT_PATH + path.parent.mkdir(parents=True) + path.write_bytes(provider.binding_bytes) + with patch.dict(os.environ, context, clear=True), patch.object(Q, "ROOT", root): + image = E.GHCR_IMAGE + "@" + INDEX + observed = Q.observe_image(provider, "compose", image, CID, platform) + self.assertEqual(observed["local_image_id"], commands.server_id) + self.assertEqual(observed["actual_image_config_digest"], provider.subjects[platform]["image_config_digest"]) + self.assertEqual(observed["config_digest_source"], "manifest_descriptor") + self.assertIn(["docker", "image", "inspect", "--platform", platform, commands.server_id], commands.reads) + observations = {key: True for key in E.COMPOSE_OBSERVATIONS} + observations["production_default_token_refused"] = None + clean = {"api_ok": True, "verified_absent": True, "container_count": 0, "network_count": 0, "volume_count": 0} + record = Q.finalize(provider, "compose", image, platform, "local", observed, observations, clean, "success", "success") + job = next(job for job in provider.job_sets[303] if job["name"] == platform + " local published image") + E.bind_receipt(record, "compose", provider.selected["compose"], provider.runs[303], job, + provider.binding["release"], platform, "local", provider.subjects[platform], + provider.selected["runner"], provider.binding) + for field, value in (("local_image_id", "sha256:" + "0" * 64), + ("local_manifest_digest", None), ("config_digest_source", "engine_id")): + with self.subTest(field=field), self.assertRaises(E.Refusal): + E.bind_receipt({**record, field: value}, "compose", provider.selected["compose"], provider.runs[303], job, + provider.binding["release"], platform, "local", provider.subjects[platform], + provider.selected["runner"], provider.binding) + + def test_containerd_manifest_config_platform_and_local_id_conflicts_refuse(self): + mutations = [ + ("container_mutation", lambda value: value.update(Image="sha256:" + "0" * 64)), + ("container_mutation", lambda value: value.pop("ImageManifestDescriptor")), + ("container_mutation", lambda value: value["ImageManifestDescriptor"].update(digest=INDEX)), + ("image_mutation", lambda value: value.update(Id=INDEX)), + ("image_mutation", lambda value: value["Descriptor"].update(digest=INDEX)), + ("image_mutation", lambda value: value.update(Architecture="arm64")), + ] + for attribute, mutation in mutations: + provider = containerd_provider() + setattr(provider.commands, attribute, mutation) + container = json.loads(provider.commands.run(["docker", "container", "inspect", CID]))[0] + with self.subTest(attribute=attribute, mutation=mutation), self.assertRaises(E.Refusal): + Q.local_image(provider, provider.subjects[E.AMD64], E.GHCR_IMAGE + "@" + INDEX, + E.AMD64, INDEX, container) + provider = containerd_provider() + provider.registry_json = lambda *args: {"schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": {"digest": INDEX}} + container = json.loads(provider.commands.run(["docker", "container", "inspect", CID]))[0] + with self.assertRaisesRegex(E.Refusal, "actual_manifest_config"): + Q.local_image(provider, provider.subjects[E.AMD64], E.GHCR_IMAGE + "@" + INDEX, + E.AMD64, INDEX, container) + + def test_third_party_descriptors_use_the_real_provider_without_expanding_its_server_allowlist(self): + fixture = containerd_provider() + commands = fixture.commands + provider = T.GitHubRegistryProvider(commands) + for role in ("mysql", "qemu"): + subject = fixture.binding[role] + with self.subTest(role=role), self.assertRaisesRegex(E.Refusal, "registry_subject"): + provider.registry_json(subject["requested_image"].split("@", 1)[0], subject["child_manifest_digest"]) + self.assertEqual(commands.reads, []) + for role in ("mysql", "qemu"): + subject = fixture.binding[role] + container = (json.loads(commands.run(["docker", "container", "inspect", "f" * 64]))[0] + if role == "mysql" else None) + observed = Q.dependency_image(provider, subject, role, container) + self.assertEqual(observed["actual_image_config_digest"], subject["image_config_digest"]) + self.assertIn(["docker", "buildx", "imagetools", "inspect", "--raw", subject["requested_image"]], commands.reads) + for role, repository in (("mysql", "evil.example/mysql"), ("mysql", E.GHCR_IMAGE), + ("mysql", "mysql:latest"), ("qemu", "mysql:8.0")): + subject = {**fixture.binding[role], "requested_image": repository + "@" + fixture.binding[role]["child_manifest_digest"]} + before = len(commands.reads) + with self.subTest(role=role, repository=repository), self.assertRaisesRegex(E.Refusal, "dependency_registry_subject"): + Q.dependency_image(provider, subject, role) + self.assertEqual(len(commands.reads), before) + class ProducerTests(unittest.TestCase): def test_cleanup_removes_only_observed_owned_objects_and_checks_absence(self):