Skip to content

Commit

Permalink
fix: enforce minimum version of docker/podman
Browse files Browse the repository at this point in the history
This allows to always pass `--platform` to the OCI engine
thus fixing issues with multiarch images.
  • Loading branch information
mayeut committed Aug 11, 2024
1 parent c6dd39b commit 0569010
Show file tree
Hide file tree
Showing 12 changed files with 220 additions and 54 deletions.
2 changes: 2 additions & 0 deletions .circleci/prepare.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ set -o xtrace

if [ "$(uname -s)" == "Darwin" ]; then
sudo softwareupdate --install-rosetta --agree-to-license
else
docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
fi

$PYTHON --version
Expand Down
2 changes: 2 additions & 0 deletions .cirrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ linux_x86_task:
memory: 8G

install_pre_requirements_script:
- docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
- apt install -y python3-venv python-is-python3
<<: *RUN_TESTS

Expand All @@ -30,6 +31,7 @@ linux_aarch64_task:
memory: 4G

install_pre_requirements_script:
- docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
- apt install -y python3-venv python-is-python3
<<: *RUN_TESTS

Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ jobs:
docker system prune -a -f
df -h
# for oci_container unit tests
- name: Set up QEMU
if: runner.os == 'Linux'
uses: docker/setup-qemu-action@v3

- name: Install dependencies
run: |
uv pip install --system ".[test]"
Expand Down Expand Up @@ -168,10 +173,7 @@ jobs:
run: python -m pip install ".[test,uv]"

- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v3
with:
platforms: all

- name: Run the emulation tests
run: pytest --run-emulation ${{ matrix.arch }} test/test_emulation.py
Expand Down
1 change: 1 addition & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ linux:
PYTEST_ADDOPTS: -k "unit_test or test_0_basic" --suppress-no-test-exit-code
script:
- curl -sSL https://get.docker.com/ | sh
- docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
- python -m pip install -e ".[dev]" pytest-custom-exit-code
- python ./bin/run_tests.py

Expand Down
1 change: 1 addition & 0 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
inputs:
versionSpec: '3.8'
- bash: |
docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
python -m pip install -e ".[dev]"
python ./bin/run_tests.py
Expand Down
4 changes: 3 additions & 1 deletion cibuildwheel/architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ def parse_config(config: str, platform: PlatformName) -> set[Architecture]:
if arch_str == "auto":
result |= Architecture.auto_archs(platform=platform)
elif arch_str == "native":
result.add(Architecture(platform_module.machine()))
native_arch = Architecture.native_arch(platform=platform)
if native_arch:
result.add(native_arch)
elif arch_str == "all":
result |= Architecture.all_archs(platform=platform)
elif arch_str == "auto64":
Expand Down
4 changes: 4 additions & 0 deletions cibuildwheel/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ def __init__(self, wheel_name: str) -> None:
)
super().__init__(message)
self.return_code = 6


class OCIEngineTooOldError(FatalError):
return_code = 7
13 changes: 11 additions & 2 deletions cibuildwheel/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from ._compat.typing import assert_never
from .architecture import Architecture
from .logger import log
from .oci_container import OCIContainer, OCIContainerEngineConfig
from .oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform
from .options import BuildOptions, Options
from .typing import PathOrStr
from .util import (
Expand All @@ -29,6 +29,14 @@
unwrap,
)

ARCHITECTURE_OCI_PLATFORM_MAP = {
Architecture.x86_64: OCIPlatform.AMD64,
Architecture.i686: OCIPlatform.i386,
Architecture.aarch64: OCIPlatform.ARM64,
Architecture.ppc64le: OCIPlatform.PPC64LE,
Architecture.s390x: OCIPlatform.S390X,
}


@dataclass(frozen=True)
class PythonConfiguration:
Expand Down Expand Up @@ -446,10 +454,11 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
log.step(f"Starting container image {build_step.container_image}...")

print(f"info: This container will host the build for {', '.join(ids_to_build)}...")
architecture = Architecture(build_step.platform_tag.split("_", 1)[1])

with OCIContainer(
image=build_step.container_image,
enforce_32_bit=build_step.platform_tag.endswith("i686"),
oci_platform=ARCHITECTURE_OCI_PLATFORM_MAP[architecture],
cwd=container_project_path,
engine=build_step.container_engine,
) as container:
Expand Down
76 changes: 69 additions & 7 deletions cibuildwheel/oci_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
import uuid
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path, PurePath, PurePosixPath
from types import TracebackType
from typing import IO, Dict, Literal

from ._compat.typing import Self
from packaging.version import Version

from ._compat.typing import Self, assert_never
from .errors import OCIEngineTooOldError
from .logger import log
from .typing import PathOrStr, PopenBytes
from .util import (
CIProvider,
Expand All @@ -29,6 +34,14 @@
ContainerEngineName = Literal["docker", "podman"]


class OCIPlatform(Enum):
AMD64 = "linux/amd64"
i386 = "linux/386"
ARM64 = "linux/arm64"
PPC64LE = "linux/ppc64le"
S390X = "linux/s390x"


@dataclass(frozen=True)
class OCIContainerEngineConfig:
name: ContainerEngineName
Expand Down Expand Up @@ -56,6 +69,15 @@ def from_config_string(config_string: str) -> OCIContainerEngineConfig:
disable_host_mount = (
strtobool(disable_host_mount_options[-1]) if disable_host_mount_options else False
)
if "--platform" in create_args or any(arg.startswith("--platform=") for arg in create_args):
msg = "Using '--platform' in 'container-engine::create_args' is deprecated. It will be ignored."
log.warning(msg)
if "--platform" in create_args:
index = create_args.index("--platform")
create_args.pop(index)
create_args.pop(index)
else:
create_args = [arg for arg in create_args if not arg.startswith("--platform=")]

return OCIContainerEngineConfig(
name=name, create_args=tuple(create_args), disable_host_mount=disable_host_mount
Expand All @@ -75,6 +97,29 @@ def options_summary(self) -> str | dict[str, str]:
DEFAULT_ENGINE = OCIContainerEngineConfig("docker")


def _check_minimum_engine_version(engine: OCIContainerEngineConfig) -> None:
try:
version_string = call(engine.name, "version", "-f", "json", capture_stdout=True).strip()
version_info = json.loads(version_string)
if engine.name == "docker":
client_api_version = Version(version_info["Client"]["ApiVersion"])
engine_api_version = Version(version_info["Server"]["ApiVersion"])
too_old = min(client_api_version, engine_api_version) < Version("1.32")
elif engine.name == "podman":
client_api_version = Version(version_info["Client"]["APIVersion"])
if "Server" in version_info:
engine_api_version = Version(version_info["Server"]["APIVersion"])
else:
engine_api_version = client_api_version
too_old = min(client_api_version, engine_api_version) < Version("3")
else:
assert_never(engine.name)
if too_old:
raise OCIEngineTooOldError() from None
except (subprocess.CalledProcessError, KeyError) as e:
raise OCIEngineTooOldError() from e


class OCIContainer:
"""
An object that represents a running OCI (e.g. Docker) container.
Expand Down Expand Up @@ -108,7 +153,7 @@ def __init__(
self,
*,
image: str,
enforce_32_bit: bool = False,
oci_platform: OCIPlatform,
cwd: PathOrStr | None = None,
engine: OCIContainerEngineConfig = DEFAULT_ENGINE,
):
Expand All @@ -117,10 +162,15 @@ def __init__(
raise ValueError(msg)

self.image = image
self.enforce_32_bit = enforce_32_bit
self.oci_platform = oci_platform
self.cwd = cwd
self.name: str | None = None
self.engine = engine
# we need '--pull=always' otherwise some images with the wrong platform get re-used (e.g. 386 image for amd64)
# c.f. https://github.com/moby/moby/issues/48197#issuecomment-2282802313
self.platform_args = [f"--platform={oci_platform.value}", "--pull=always"]

_check_minimum_engine_version(self.engine)

def __enter__(self) -> Self:
self.name = f"cibuildwheel-{uuid.uuid4()}"
Expand All @@ -134,13 +184,24 @@ def __enter__(self) -> Self:
network_args = ["--network=host"]

simulate_32_bit = False
if self.enforce_32_bit:
if self.oci_platform == OCIPlatform.i386:
# If the architecture running the image is already the right one
# or the image entrypoint takes care of enforcing this, then we don't need to
# simulate this
container_machine = call(
self.engine.name, "run", "--rm", self.image, "uname", "-m", capture_stdout=True
).strip()
run_cmd = [self.engine.name, "run", "--rm"]
ctr_cmd = ["uname", "-m"]
try:
container_machine = call(
*run_cmd, *self.platform_args, self.image, *ctr_cmd, capture_stdout=True
).strip()
except subprocess.CalledProcessError:
# The image might have been built with amd64 architecture
# Let's try that
platform_args = ["--platform=linux/amd64", *self.platform_args[1:]]
container_machine = call(
*run_cmd, *platform_args, self.image, *ctr_cmd, capture_stdout=True
).strip()
self.platform_args = platform_args
simulate_32_bit = container_machine != "i686"

shell_args = ["linux32", "/bin/bash"] if simulate_32_bit else ["/bin/bash"]
Expand All @@ -155,6 +216,7 @@ def __enter__(self) -> Self:
"--interactive",
*(["--volume=/:/host"] if not self.engine.disable_host_mount else []),
*network_args,
*self.platform_args,
*self.engine.create_args,
self.image,
*shell_args,
Expand Down
6 changes: 2 additions & 4 deletions test/test_container_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,15 @@ def test_podman(tmp_path, capfd, request):
actual_wheels = utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_ARCHS": "x86_64",
"CIBW_ARCHS": "native",
"CIBW_BEFORE_ALL": "echo 'test log statement from before-all'",
"CIBW_CONTAINER_ENGINE": "podman",
},
single_python=True,
)

# check that the expected wheels are produced
expected_wheels = [
w for w in utils.expected_wheels("spam", "0.1.0", single_python=True) if "x86_64" in w
]
expected_wheels = utils.expected_wheels("spam", "0.1.0", single_python=True, single_arch=True)
assert set(actual_wheels) == set(expected_wheels)

# check that stdout is bring passed-though from container correctly
Expand Down
Loading

0 comments on commit 0569010

Please sign in to comment.