release: fix RC-04 single-platform archive export
This commit is contained in:
@@ -1743,29 +1743,14 @@ def inspect_oci_archive(path: Path) -> dict[str, Any]:
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
}:
|
||||
nested = _require_mapping(load_json_bytes(manifest_data, label="nested OCI index"), "nested OCI index")
|
||||
nested_descriptors = _require_list(nested.get("manifests"), "nested OCI index manifests")
|
||||
matches = []
|
||||
for candidate in nested_descriptors:
|
||||
platform = _require_mapping(
|
||||
_require_mapping(candidate, "nested OCI descriptor").get("platform"),
|
||||
"nested OCI descriptor platform",
|
||||
)
|
||||
if platform.get("os") == "linux" and platform.get("architecture") == "amd64":
|
||||
matches.append(candidate)
|
||||
if len(matches) != 1:
|
||||
raise ReleaseValidationError("OCI archive does not contain exactly one linux/amd64 image")
|
||||
descriptor, manifest_data = _verify_oci_descriptor(
|
||||
archive, matches[0], label="OCI linux/amd64 manifest descriptor"
|
||||
raise ReleaseValidationError(
|
||||
"OCI top-level descriptor must reference one image manifest directly, not an image index"
|
||||
)
|
||||
assert manifest_data is not None
|
||||
media_type = descriptor.get("mediaType")
|
||||
else:
|
||||
platform = descriptor.get("platform")
|
||||
if isinstance(platform, dict) and (
|
||||
platform.get("os") != "linux" or platform.get("architecture") != "amd64"
|
||||
):
|
||||
raise ReleaseValidationError("OCI descriptor platform is not linux/amd64")
|
||||
platform = descriptor.get("platform")
|
||||
if isinstance(platform, dict) and (
|
||||
platform.get("os") != "linux" or platform.get("architecture") != "amd64"
|
||||
):
|
||||
raise ReleaseValidationError("OCI descriptor platform is not linux/amd64")
|
||||
if media_type not in {
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
|
||||
@@ -897,6 +897,64 @@ def validate_release_inputs(
|
||||
}
|
||||
|
||||
|
||||
def _image_build_command(
|
||||
*,
|
||||
spec: Mapping[str, Any],
|
||||
commit: str,
|
||||
tag: str,
|
||||
builder_name: str,
|
||||
source_date_epoch: int,
|
||||
dockerfile: str | Path,
|
||||
oci_output: str | Path,
|
||||
docker_output: str | Path,
|
||||
) -> list[str]:
|
||||
"""Return the single-platform build command shared by plan and execution.
|
||||
|
||||
``BUILDKIT_MULTI_PLATFORM=1`` is intentionally absent. Even with one
|
||||
explicit target platform, that BuildKit option changes the result into a
|
||||
manifest list, which the required Docker archive exporter rejects.
|
||||
"""
|
||||
|
||||
role = spec["role"]
|
||||
image_ref = f"{spec['name']}:{commit}"
|
||||
return [
|
||||
PINNED_EXECUTABLES["docker"],
|
||||
"--host",
|
||||
LOCAL_DOCKER_HOST,
|
||||
"buildx",
|
||||
"build",
|
||||
"--builder",
|
||||
builder_name,
|
||||
"--platform",
|
||||
TARGET["platform"],
|
||||
"--file",
|
||||
str(dockerfile),
|
||||
"--tag",
|
||||
image_ref,
|
||||
"--label",
|
||||
f"org.opencontainers.image.revision={commit}",
|
||||
"--label",
|
||||
f"org.opencontainers.image.version={tag}",
|
||||
"--label",
|
||||
f"org.opencontainers.image.title={spec['name']}",
|
||||
"--label",
|
||||
f"io.yuqei.release.role={role}",
|
||||
"--build-arg",
|
||||
f"SOURCE_DATE_EPOCH={source_date_epoch}",
|
||||
"--build-arg",
|
||||
f"{spec['base_image']['build_arg']}={spec['base_image']['transport_reference']}",
|
||||
"--pull",
|
||||
"--no-cache",
|
||||
"--provenance=false",
|
||||
"--sbom=false",
|
||||
"--output",
|
||||
f"type=oci,dest={oci_output}",
|
||||
"--output",
|
||||
f"type=docker,dest={docker_output}",
|
||||
".",
|
||||
]
|
||||
|
||||
|
||||
def create_build_plan(
|
||||
*,
|
||||
repo: Path,
|
||||
@@ -980,47 +1038,16 @@ def create_build_plan(
|
||||
"scan": scan_path,
|
||||
},
|
||||
"commands": [
|
||||
[
|
||||
PINNED_EXECUTABLES["docker"],
|
||||
"--host",
|
||||
LOCAL_DOCKER_HOST,
|
||||
"buildx",
|
||||
"build",
|
||||
"--builder",
|
||||
builder_name,
|
||||
"--platform",
|
||||
TARGET["platform"],
|
||||
"--file",
|
||||
spec["dockerfile"],
|
||||
"--tag",
|
||||
image_ref,
|
||||
"--label",
|
||||
f"org.opencontainers.image.revision={commit}",
|
||||
"--label",
|
||||
f"org.opencontainers.image.version={tag}",
|
||||
"--label",
|
||||
f"org.opencontainers.image.title={spec['name']}",
|
||||
"--label",
|
||||
f"io.yuqei.release.role={role}",
|
||||
"--build-arg",
|
||||
f"SOURCE_DATE_EPOCH={source_date_epoch}",
|
||||
"--build-arg",
|
||||
"BUILDKIT_MULTI_PLATFORM=1",
|
||||
"--build-arg",
|
||||
(
|
||||
f"{spec['base_image']['build_arg']}="
|
||||
f"{spec['base_image']['transport_reference']}"
|
||||
),
|
||||
"--pull",
|
||||
"--no-cache",
|
||||
"--provenance=false",
|
||||
"--sbom=false",
|
||||
"--output",
|
||||
f"type=oci,dest=<output-dir>/{oci_path}",
|
||||
"--output",
|
||||
f"type=docker,dest=<output-dir>/{docker_path}",
|
||||
".",
|
||||
],
|
||||
_image_build_command(
|
||||
spec=spec,
|
||||
commit=commit,
|
||||
tag=tag,
|
||||
builder_name=builder_name,
|
||||
source_date_epoch=source_date_epoch,
|
||||
dockerfile=spec["dockerfile"],
|
||||
oci_output=f"<output-dir>/{oci_path}",
|
||||
docker_output=f"<output-dir>/{docker_path}",
|
||||
),
|
||||
[
|
||||
PINNED_EXECUTABLES["syft"],
|
||||
"scan",
|
||||
@@ -1370,47 +1397,16 @@ def execute_build(
|
||||
scan_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
runner(
|
||||
[
|
||||
PINNED_EXECUTABLES["docker"],
|
||||
"--host",
|
||||
LOCAL_DOCKER_HOST,
|
||||
"buildx",
|
||||
"build",
|
||||
"--builder",
|
||||
builder_name,
|
||||
"--platform",
|
||||
TARGET["platform"],
|
||||
"--file",
|
||||
str(dockerfile),
|
||||
"--tag",
|
||||
image_ref,
|
||||
"--label",
|
||||
f"org.opencontainers.image.revision={commit}",
|
||||
"--label",
|
||||
f"org.opencontainers.image.version={tag}",
|
||||
"--label",
|
||||
f"org.opencontainers.image.title={spec['name']}",
|
||||
"--label",
|
||||
f"io.yuqei.release.role={role}",
|
||||
"--build-arg",
|
||||
f"SOURCE_DATE_EPOCH={source_date_epoch}",
|
||||
"--build-arg",
|
||||
"BUILDKIT_MULTI_PLATFORM=1",
|
||||
"--build-arg",
|
||||
(
|
||||
f"{spec['base_image']['build_arg']}="
|
||||
f"{spec['base_image']['transport_reference']}"
|
||||
),
|
||||
"--pull",
|
||||
"--no-cache",
|
||||
"--provenance=false",
|
||||
"--sbom=false",
|
||||
"--output",
|
||||
f"type=oci,dest={oci_path}",
|
||||
"--output",
|
||||
f"type=docker,dest={docker_path}",
|
||||
".",
|
||||
],
|
||||
_image_build_command(
|
||||
spec=spec,
|
||||
commit=commit,
|
||||
tag=tag,
|
||||
builder_name=builder_name,
|
||||
source_date_epoch=source_date_epoch,
|
||||
dockerfile=dockerfile,
|
||||
oci_output=oci_path,
|
||||
docker_output=docker_path,
|
||||
),
|
||||
cwd=source_root,
|
||||
timeout_seconds=COMMAND_TIMEOUTS_SECONDS["image_build"],
|
||||
)
|
||||
|
||||
@@ -288,6 +288,52 @@ def _write_oci(
|
||||
return common.inspect_oci_archive(path)
|
||||
|
||||
|
||||
def _wrap_oci_in_nested_index(path: Path, platforms: list[dict[str, str]]) -> None:
|
||||
with tarfile.open(path, "r") as archive:
|
||||
entries: dict[str, bytes] = {}
|
||||
for member in archive.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
source = archive.extractfile(member)
|
||||
assert source is not None
|
||||
entries[member.name] = source.read()
|
||||
outer_index = json.loads(entries["index.json"])
|
||||
image_descriptor = outer_index["manifests"][0]
|
||||
nested = json.dumps(
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": [
|
||||
{**image_descriptor, "platform": platform} for platform in platforms
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
nested_digest = hashlib.sha256(nested).hexdigest()
|
||||
entries[f"blobs/sha256/{nested_digest}"] = nested
|
||||
entries["index.json"] = json.dumps(
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"digest": f"sha256:{nested_digest}",
|
||||
"size": len(nested),
|
||||
}
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
with tarfile.open(path, "w") as archive:
|
||||
for name, data in sorted(entries.items()):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.mode = 0o644
|
||||
info.size = len(data)
|
||||
archive.addfile(info, BytesIO(data))
|
||||
|
||||
|
||||
def _write_docker_archive(
|
||||
path: Path,
|
||||
role: str,
|
||||
@@ -815,12 +861,38 @@ def test_plan_mode_validates_commit_tag_manifest_archive_and_orders_four_images(
|
||||
assert plan["compose_gate"]["execution_engine"] == "self_bound_release_runner_builtin_v1"
|
||||
assert not (tmp_path / "never-created").exists()
|
||||
for image in plan["images"]:
|
||||
build_command = image["commands"][0]
|
||||
flattened = " ".join(" ".join(command) for command in image["commands"])
|
||||
assert "linux/amd64" in flattened
|
||||
assert "spdx-json" in flattened
|
||||
assert "HIGH,CRITICAL" in flattened
|
||||
assert "type=oci" in flattened and "type=docker" in flattened
|
||||
assert "--builder fixture-builder" in flattened
|
||||
assert build_command.count("--platform") == 1
|
||||
platform_index = build_command.index("--platform")
|
||||
assert build_command[platform_index + 1] == "linux/amd64"
|
||||
output_indexes = [
|
||||
index for index, token in enumerate(build_command) if token == "--output"
|
||||
]
|
||||
assert [build_command[index + 1].split(",", 1)[0] for index in output_indexes] == [
|
||||
"type=oci",
|
||||
"type=docker",
|
||||
]
|
||||
assert "--provenance=false" in build_command
|
||||
assert "--sbom=false" in build_command
|
||||
build_arg_indexes = [
|
||||
index for index, token in enumerate(build_command) if token == "--build-arg"
|
||||
]
|
||||
assert [build_command[index + 1] for index in build_arg_indexes] == [
|
||||
f"SOURCE_DATE_EPOCH={source['epoch']}",
|
||||
(
|
||||
f"{image['base_image']['build_arg']}="
|
||||
f"{image['base_image']['transport_reference']}"
|
||||
),
|
||||
]
|
||||
# Forcing BuildKit's multi-platform result shape makes the Docker
|
||||
# archive exporter reject even a one-target result as a manifest list.
|
||||
assert all("BUILDKIT_MULTI_PLATFORM" not in token for token in build_command)
|
||||
assert common.TRIVY_DB_REPOSITORY_TRANSPORT["reference"] in flattened
|
||||
trivy_command = image["commands"][2]
|
||||
assert trivy_command[trivy_command.index("--input") + 1].endswith(".docker.tar")
|
||||
@@ -1582,6 +1654,31 @@ def test_docker_archive_rejects_layer_content_not_bound_to_config(tmp_path: Path
|
||||
common.inspect_docker_archive(path, expected_repo_tag=image_ref)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platforms",
|
||||
[
|
||||
[{"os": "linux", "architecture": "amd64"}],
|
||||
[
|
||||
{"os": "linux", "architecture": "amd64"},
|
||||
{"os": "linux", "architecture": "arm64"},
|
||||
],
|
||||
],
|
||||
ids=["single-item-index", "multi-platform-index"],
|
||||
)
|
||||
def test_oci_archive_rejects_nested_image_index(
|
||||
tmp_path: Path, platforms: list[dict[str, str]]
|
||||
) -> None:
|
||||
path = tmp_path / "nested-index.oci.tar"
|
||||
_write_oci(path, "contract-api", "yuqei/contract-api", "a" * 40, "v2-test")
|
||||
_wrap_oci_in_nested_index(path, platforms)
|
||||
|
||||
with pytest.raises(
|
||||
common.ReleaseValidationError,
|
||||
match="must reference one image manifest directly",
|
||||
):
|
||||
common.inspect_oci_archive(path)
|
||||
|
||||
|
||||
def test_oci_archive_rejects_descriptor_valid_layer_not_bound_to_config_diff_id(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user