Skip to content

Commit 0569010

Browse files
committed
fix: enforce minimum version of docker/podman
This allows to always pass `--platform` to the OCI engine thus fixing issues with multiarch images.
1 parent c6dd39b commit 0569010

File tree

12 files changed

+220
-54
lines changed

12 files changed

+220
-54
lines changed

.circleci/prepare.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ set -o xtrace
44

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

911
$PYTHON --version

.cirrus.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ linux_x86_task:
1717
memory: 8G
1818

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

@@ -30,6 +31,7 @@ linux_aarch64_task:
3031
memory: 4G
3132

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

.github/workflows/test.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ jobs:
7070
docker system prune -a -f
7171
df -h
7272
73+
# for oci_container unit tests
74+
- name: Set up QEMU
75+
if: runner.os == 'Linux'
76+
uses: docker/setup-qemu-action@v3
77+
7378
- name: Install dependencies
7479
run: |
7580
uv pip install --system ".[test]"
@@ -168,10 +173,7 @@ jobs:
168173
run: python -m pip install ".[test,uv]"
169174

170175
- name: Set up QEMU
171-
id: qemu
172176
uses: docker/setup-qemu-action@v3
173-
with:
174-
platforms: all
175177

176178
- name: Run the emulation tests
177179
run: pytest --run-emulation ${{ matrix.arch }} test/test_emulation.py

.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ linux:
1515
PYTEST_ADDOPTS: -k "unit_test or test_0_basic" --suppress-no-test-exit-code
1616
script:
1717
- curl -sSL https://get.docker.com/ | sh
18+
- docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
1819
- python -m pip install -e ".[dev]" pytest-custom-exit-code
1920
- python ./bin/run_tests.py
2021

azure-pipelines.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jobs:
1313
inputs:
1414
versionSpec: '3.8'
1515
- bash: |
16+
docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all
1617
python -m pip install -e ".[dev]"
1718
python ./bin/run_tests.py
1819

cibuildwheel/architecture.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ def parse_config(config: str, platform: PlatformName) -> set[Architecture]:
6464
if arch_str == "auto":
6565
result |= Architecture.auto_archs(platform=platform)
6666
elif arch_str == "native":
67-
result.add(Architecture(platform_module.machine()))
67+
native_arch = Architecture.native_arch(platform=platform)
68+
if native_arch:
69+
result.add(native_arch)
6870
elif arch_str == "all":
6971
result |= Architecture.all_archs(platform=platform)
7072
elif arch_str == "auto64":

cibuildwheel/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,7 @@ def __init__(self, wheel_name: str) -> None:
5858
)
5959
super().__init__(message)
6060
self.return_code = 6
61+
62+
63+
class OCIEngineTooOldError(FatalError):
64+
return_code = 7

cibuildwheel/linux.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from ._compat.typing import assert_never
1515
from .architecture import Architecture
1616
from .logger import log
17-
from .oci_container import OCIContainer, OCIContainerEngineConfig
17+
from .oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform
1818
from .options import BuildOptions, Options
1919
from .typing import PathOrStr
2020
from .util import (
@@ -29,6 +29,14 @@
2929
unwrap,
3030
)
3131

32+
ARCHITECTURE_OCI_PLATFORM_MAP = {
33+
Architecture.x86_64: OCIPlatform.AMD64,
34+
Architecture.i686: OCIPlatform.i386,
35+
Architecture.aarch64: OCIPlatform.ARM64,
36+
Architecture.ppc64le: OCIPlatform.PPC64LE,
37+
Architecture.s390x: OCIPlatform.S390X,
38+
}
39+
3240

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

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

450459
with OCIContainer(
451460
image=build_step.container_image,
452-
enforce_32_bit=build_step.platform_tag.endswith("i686"),
461+
oci_platform=ARCHITECTURE_OCI_PLATFORM_MAP[architecture],
453462
cwd=container_project_path,
454463
engine=build_step.container_engine,
455464
) as container:

cibuildwheel/oci_container.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@
1212
import uuid
1313
from collections.abc import Mapping, Sequence
1414
from dataclasses import dataclass, field
15+
from enum import Enum
1516
from pathlib import Path, PurePath, PurePosixPath
1617
from types import TracebackType
1718
from typing import IO, Dict, Literal
1819

19-
from ._compat.typing import Self
20+
from packaging.version import Version
21+
22+
from ._compat.typing import Self, assert_never
23+
from .errors import OCIEngineTooOldError
24+
from .logger import log
2025
from .typing import PathOrStr, PopenBytes
2126
from .util import (
2227
CIProvider,
@@ -29,6 +34,14 @@
2934
ContainerEngineName = Literal["docker", "podman"]
3035

3136

37+
class OCIPlatform(Enum):
38+
AMD64 = "linux/amd64"
39+
i386 = "linux/386"
40+
ARM64 = "linux/arm64"
41+
PPC64LE = "linux/ppc64le"
42+
S390X = "linux/s390x"
43+
44+
3245
@dataclass(frozen=True)
3346
class OCIContainerEngineConfig:
3447
name: ContainerEngineName
@@ -56,6 +69,15 @@ def from_config_string(config_string: str) -> OCIContainerEngineConfig:
5669
disable_host_mount = (
5770
strtobool(disable_host_mount_options[-1]) if disable_host_mount_options else False
5871
)
72+
if "--platform" in create_args or any(arg.startswith("--platform=") for arg in create_args):
73+
msg = "Using '--platform' in 'container-engine::create_args' is deprecated. It will be ignored."
74+
log.warning(msg)
75+
if "--platform" in create_args:
76+
index = create_args.index("--platform")
77+
create_args.pop(index)
78+
create_args.pop(index)
79+
else:
80+
create_args = [arg for arg in create_args if not arg.startswith("--platform=")]
5981

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

7799

100+
def _check_minimum_engine_version(engine: OCIContainerEngineConfig) -> None:
101+
try:
102+
version_string = call(engine.name, "version", "-f", "json", capture_stdout=True).strip()
103+
version_info = json.loads(version_string)
104+
if engine.name == "docker":
105+
client_api_version = Version(version_info["Client"]["ApiVersion"])
106+
engine_api_version = Version(version_info["Server"]["ApiVersion"])
107+
too_old = min(client_api_version, engine_api_version) < Version("1.32")
108+
elif engine.name == "podman":
109+
client_api_version = Version(version_info["Client"]["APIVersion"])
110+
if "Server" in version_info:
111+
engine_api_version = Version(version_info["Server"]["APIVersion"])
112+
else:
113+
engine_api_version = client_api_version
114+
too_old = min(client_api_version, engine_api_version) < Version("3")
115+
else:
116+
assert_never(engine.name)
117+
if too_old:
118+
raise OCIEngineTooOldError() from None
119+
except (subprocess.CalledProcessError, KeyError) as e:
120+
raise OCIEngineTooOldError() from e
121+
122+
78123
class OCIContainer:
79124
"""
80125
An object that represents a running OCI (e.g. Docker) container.
@@ -108,7 +153,7 @@ def __init__(
108153
self,
109154
*,
110155
image: str,
111-
enforce_32_bit: bool = False,
156+
oci_platform: OCIPlatform,
112157
cwd: PathOrStr | None = None,
113158
engine: OCIContainerEngineConfig = DEFAULT_ENGINE,
114159
):
@@ -117,10 +162,15 @@ def __init__(
117162
raise ValueError(msg)
118163

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

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

136186
simulate_32_bit = False
137-
if self.enforce_32_bit:
187+
if self.oci_platform == OCIPlatform.i386:
138188
# If the architecture running the image is already the right one
139189
# or the image entrypoint takes care of enforcing this, then we don't need to
140190
# simulate this
141-
container_machine = call(
142-
self.engine.name, "run", "--rm", self.image, "uname", "-m", capture_stdout=True
143-
).strip()
191+
run_cmd = [self.engine.name, "run", "--rm"]
192+
ctr_cmd = ["uname", "-m"]
193+
try:
194+
container_machine = call(
195+
*run_cmd, *self.platform_args, self.image, *ctr_cmd, capture_stdout=True
196+
).strip()
197+
except subprocess.CalledProcessError:
198+
# The image might have been built with amd64 architecture
199+
# Let's try that
200+
platform_args = ["--platform=linux/amd64", *self.platform_args[1:]]
201+
container_machine = call(
202+
*run_cmd, *platform_args, self.image, *ctr_cmd, capture_stdout=True
203+
).strip()
204+
self.platform_args = platform_args
144205
simulate_32_bit = container_machine != "i686"
145206

146207
shell_args = ["linux32", "/bin/bash"] if simulate_32_bit else ["/bin/bash"]
@@ -155,6 +216,7 @@ def __enter__(self) -> Self:
155216
"--interactive",
156217
*(["--volume=/:/host"] if not self.engine.disable_host_mount else []),
157218
*network_args,
219+
*self.platform_args,
158220
*self.engine.create_args,
159221
self.image,
160222
*shell_args,

test/test_container_engine.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,15 @@ def test_podman(tmp_path, capfd, request):
2121
actual_wheels = utils.cibuildwheel_run(
2222
project_dir,
2323
add_env={
24-
"CIBW_ARCHS": "x86_64",
24+
"CIBW_ARCHS": "native",
2525
"CIBW_BEFORE_ALL": "echo 'test log statement from before-all'",
2626
"CIBW_CONTAINER_ENGINE": "podman",
2727
},
2828
single_python=True,
2929
)
3030

3131
# check that the expected wheels are produced
32-
expected_wheels = [
33-
w for w in utils.expected_wheels("spam", "0.1.0", single_python=True) if "x86_64" in w
34-
]
32+
expected_wheels = utils.expected_wheels("spam", "0.1.0", single_python=True, single_arch=True)
3533
assert set(actual_wheels) == set(expected_wheels)
3634

3735
# check that stdout is bring passed-though from container correctly

0 commit comments

Comments
 (0)