Files
cibuildwheel/cibuildwheel/oci_container.py
T

353 lines
12 KiB
Python
Raw Normal View History

2020-06-24 16:43:33 +01:00
import io
import json
import os
2022-01-06 08:55:49 +01:00
import platform
2020-06-24 16:43:33 +01:00
import shlex
2022-05-24 17:35:46 -06:00
import shutil
2020-06-24 16:43:33 +01:00
import subprocess
import sys
import uuid
2022-05-24 17:35:46 -06:00
from pathlib import Path, PurePath, PurePosixPath
from types import TracebackType
2021-01-06 13:50:58 -05:00
from typing import IO, Dict, List, Optional, Sequence, Type, cast
2022-01-06 08:55:49 +01:00
from cibuildwheel.util import CIProvider, detect_ci_provider
from .typing import Literal, PathOrStr, PopenBytes
ContainerEngine = Literal["docker", "podman"]
2020-06-24 16:43:33 +01:00
class OCIContainer:
2021-05-03 11:45:43 -04:00
"""
An object that represents a running OCI (e.g. Docker) container.
2020-06-24 16:43:33 +01:00
Intended for use as a context manager e.g.
`with OCIContainer(image = 'ubuntu') as docker:`
2020-06-24 16:43:33 +01:00
A bash shell is running in the remote container. When `call()` is invoked,
the command is relayed to the remote shell, and the results are streamed
back to cibuildwheel.
2022-06-16 18:11:59 -04:00
Example:
>>> from cibuildwheel.docker_container import * # NOQA
>>> from cibuildwheel.options import _get_pinned_container_images
>>> image = _get_pinned_container_images()['x86_64']['manylinux2014']
2022-06-16 18:11:59 -04:00
>>> # Test the default container
>>> with OCIContainer(image=image) as self:
2022-06-16 18:11:59 -04:00
... self.call(["echo", "hello world"])
... self.call(["cat", "/proc/1/cgroup"])
... print(self.get_environment())
2022-06-23 11:08:09 -04:00
... print(self.debug_info())
2021-05-03 11:45:43 -04:00
"""
2021-04-30 17:56:34 -04:00
2021-05-03 11:45:43 -04:00
UTILITY_PYTHON = "/opt/python/cp38-cp38/bin/python"
2020-06-24 16:43:33 +01:00
process: PopenBytes
bash_stdin: IO[bytes]
bash_stdout: IO[bytes]
2020-06-24 16:43:33 +01:00
2021-04-30 17:56:34 -04:00
def __init__(
2022-06-16 18:11:59 -04:00
self,
*,
image: str,
2022-06-16 18:11:59 -04:00
simulate_32_bit: bool = False,
cwd: Optional[PathOrStr] = None,
engine: ContainerEngine = "docker",
2021-04-30 17:56:34 -04:00
):
if not image:
raise ValueError("Must have a non-empty image to run.")
2021-01-17 13:08:55 -05:00
self.image = image
self.simulate_32_bit = simulate_32_bit
2020-07-20 01:04:26 +02:00
self.cwd = cwd
self.name: Optional[str] = None
self.engine = engine
2020-06-24 16:43:33 +01:00
def __enter__(self) -> "OCIContainer":
2022-06-16 18:11:59 -04:00
2021-05-03 11:45:43 -04:00
self.name = f"cibuildwheel-{uuid.uuid4()}"
2022-01-06 08:55:49 +01:00
# work-around for Travis-CI PPC64le Docker runs since 2021:
# this avoids network splits
# https://github.com/pypa/cibuildwheel/issues/904
# https://github.com/conda-forge/conda-smithy/pull/1520
network_args = []
if detect_ci_provider() == CIProvider.travis_ci and platform.machine() == "ppc64le":
network_args = ["--network=host"]
2021-05-03 11:45:43 -04:00
shell_args = ["linux32", "/bin/bash"] if self.simulate_32_bit else ["/bin/bash"]
2020-06-24 16:43:33 +01:00
subprocess.run(
[
self.engine,
2021-05-03 11:45:43 -04:00
"create",
"--env=CIBUILDWHEEL",
f"--name={self.name}",
"--interactive",
2022-06-26 17:10:02 -04:00
"--volume=/:/host", # ignored on CircleCI
2022-01-06 08:55:49 +01:00
*network_args,
self.image,
2021-04-30 17:56:34 -04:00
*shell_args,
2020-06-24 16:43:33 +01:00
],
check=True,
)
2022-06-16 18:11:59 -04:00
2020-06-26 10:59:47 +01:00
self.process = subprocess.Popen(
2020-06-24 16:43:33 +01:00
[
self.engine,
2021-05-03 11:45:43 -04:00
"start",
"--attach",
"--interactive",
self.name,
2020-06-24 16:43:33 +01:00
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
2020-06-26 10:59:47 +01:00
assert self.process.stdin and self.process.stdout
self.bash_stdin = self.process.stdin
self.bash_stdout = self.process.stdout
2020-07-08 22:56:33 +01:00
# run a noop command to block until the container is responding
2022-06-26 17:10:02 -04:00
self.call(["/bin/true"])
2022-06-16 18:11:59 -04:00
if self.cwd:
# Although `docker create -w` does create the working dir if it
# does not exist, podman does not. There does not seem to be a way
# to setup a workdir for a container running in podman.
2022-06-26 17:10:02 -04:00
self.call(["mkdir", "-p", os.fspath(self.cwd)])
2020-07-08 22:56:33 +01:00
2020-06-24 16:43:33 +01:00
return self
def __exit__(
2021-04-30 17:56:34 -04:00
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
self.bash_stdin.write(b"exit 0\n")
self.bash_stdin.flush()
self.process.wait(timeout=30)
2020-06-24 16:43:33 +01:00
self.bash_stdin.close()
self.bash_stdout.close()
2020-06-24 16:43:33 +01:00
if self.engine == "podman":
2022-06-16 18:11:59 -04:00
# This works around what seems to be a race condition in the podman
# backend. The full reason is not understood. See PR #966 for a
# discussion on possible causes and attempts to remove this line.
# For now, this seems to work "well enough".
self.process.wait()
assert isinstance(self.name, str)
subprocess.run(
[self.engine, "rm", "--force", "-v", self.name],
2022-06-16 18:11:59 -04:00
stdout=subprocess.DEVNULL,
check=False,
)
self.name = None
2020-06-24 16:43:33 +01:00
def copy_into(self, from_path: Path, to_path: PurePath) -> None:
# `docker cp` causes 'no space left on device' error when
# a container is running and the host filesystem is
# mounted. https://github.com/moby/moby/issues/38995
# Use `docker exec` instead.
2020-07-10 13:04:50 +01:00
2020-06-24 16:43:33 +01:00
if from_path.is_dir():
2021-05-03 11:45:43 -04:00
self.call(["mkdir", "-p", to_path])
2020-06-24 16:43:33 +01:00
subprocess.run(
f"tar cf - . | {self.engine} exec -i {self.name} tar --no-same-owner -xC {shell_quote(to_path)} -f -",
2020-06-24 16:43:33 +01:00
shell=True,
check=True,
2021-04-30 17:56:34 -04:00
cwd=from_path,
)
2020-06-24 16:43:33 +01:00
else:
2022-05-25 18:04:54 -06:00
with subprocess.Popen(
2022-05-24 17:35:46 -06:00
[
self.engine,
2022-05-24 17:35:46 -06:00
"exec",
"-i",
str(self.name),
"sh",
"-c",
f"cat > {shell_quote(to_path)}",
],
stdin=subprocess.PIPE,
) as exec_process:
exec_process.stdin = cast(IO[bytes], exec_process.stdin)
2022-05-24 17:35:46 -06:00
2022-05-25 18:04:54 -06:00
with open(from_path, "rb") as from_file:
shutil.copyfileobj(from_file, exec_process.stdin)
2022-05-24 17:35:46 -06:00
exec_process.stdin.close()
exec_process.wait()
2022-05-24 17:35:46 -06:00
if exec_process.returncode:
raise subprocess.CalledProcessError(
exec_process.returncode, exec_process.args, None, None
)
2020-06-24 16:43:33 +01:00
def copy_out(self, from_path: PurePath, to_path: Path) -> None:
# note: we assume from_path is a dir
to_path.mkdir(parents=True, exist_ok=True)
if self.engine == "podman":
2022-06-26 15:53:44 -04:00
subprocess.run(
2022-06-27 15:23:50 +01:00
[
self.engine,
2022-06-27 15:23:50 +01:00
"cp",
f"{self.name}:{from_path}/.",
str(to_path),
],
2022-06-26 15:53:44 -04:00
check=True,
cwd=to_path,
)
elif self.engine == "docker":
# There is a bug in docker that prevents a simple 'cp' invocation
# from working https://github.com/moby/moby/issues/38995
command = f"{self.engine} exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -"
2022-06-16 18:11:59 -04:00
subprocess.run(
command,
shell=True,
check=True,
cwd=to_path,
)
else:
raise KeyError(self.engine)
2020-06-24 16:43:33 +01:00
2022-05-24 17:35:46 -06:00
def glob(self, path: PurePosixPath, pattern: str) -> List[PurePosixPath]:
glob_pattern = path.joinpath(pattern)
path_strings = json.loads(
2021-04-30 17:56:34 -04:00
self.call(
[
self.UTILITY_PYTHON,
2021-05-03 11:45:43 -04:00
"-c",
2022-05-24 17:35:46 -06:00
f"import sys, json, glob; json.dump(glob.glob({str(glob_pattern)!r}), sys.stdout)",
2021-04-30 17:56:34 -04:00
],
capture_output=True,
)
)
2020-06-24 16:43:33 +01:00
2022-05-24 17:35:46 -06:00
return [PurePosixPath(p) for p in path_strings]
2020-06-24 16:43:33 +01:00
def call(
2021-04-30 17:56:34 -04:00
self,
args: Sequence[PathOrStr],
env: Optional[Dict[str, str]] = None,
capture_output: bool = False,
cwd: Optional[PathOrStr] = None,
) -> str:
2020-08-14 16:12:24 -04:00
2022-06-16 18:11:59 -04:00
if cwd is None:
# Podman does not start the a container in a specific working dir
# so we always need to specify it when making calls.
cwd = self.cwd
2021-05-03 11:45:43 -04:00
chdir = f"cd {cwd}" if cwd else ""
2021-04-30 17:56:34 -04:00
env_assignments = (
2021-05-03 11:45:43 -04:00
" ".join(f"{shlex.quote(k)}={shlex.quote(v)}" for k, v in env.items())
2021-04-30 17:56:34 -04:00
if env is not None
2021-05-03 11:45:43 -04:00
else ""
2021-04-30 17:56:34 -04:00
)
2021-05-03 11:45:43 -04:00
command = " ".join(shlex.quote(str(a)) for a in args)
2020-06-24 16:43:33 +01:00
end_of_message = str(uuid.uuid4())
# log the command we're executing
2021-05-03 11:45:43 -04:00
print(f" + {command}")
2020-06-24 16:43:33 +01:00
# Write a command to the remote shell. First we change the
# cwd, if that's required. Then, we use the `env` utility to run
# `command` inside the specified environment. We use `env` because it
# can cope with spaces and strange characters in the name or value.
# Finally, the remote shell is told to write a footer - this will show
# up in the output so we know when to stop reading, and will include
# the return code of `command`.
2021-04-30 17:56:34 -04:00
self.bash_stdin.write(
bytes(
2021-05-03 11:45:43 -04:00
f"""(
2020-06-24 16:43:33 +01:00
{chdir}
env {env_assignments} {command}
2020-06-24 16:43:33 +01:00
printf "%04d%s\n" $? {end_of_message}
)
2021-05-03 11:45:43 -04:00
""",
encoding="utf8",
errors="surrogateescape",
2021-04-30 17:56:34 -04:00
)
)
2020-06-24 16:43:33 +01:00
self.bash_stdin.flush()
if capture_output:
output_io: IO[bytes] = io.BytesIO()
2020-06-24 16:43:33 +01:00
else:
output_io = sys.stdout.buffer
2020-06-24 16:43:33 +01:00
while True:
line = self.bash_stdout.readline()
2021-05-03 11:45:43 -04:00
if line.endswith(bytes(end_of_message, encoding="utf8") + b"\n"):
2021-04-29 20:21:42 -04:00
# fmt: off
2020-06-24 16:43:33 +01:00
footer_offset = (
len(line)
- 1 # newline character
- len(end_of_message) # delimiter
- 4 # 4 return code decimals
2020-06-24 16:43:33 +01:00
)
2021-04-29 20:21:42 -04:00
# fmt: on
return_code_str = line[footer_offset : footer_offset + 4]
return_code = int(return_code_str)
2020-06-24 16:43:33 +01:00
# add the last line to output, without the footer
output_io.write(line[0:footer_offset])
break
else:
output_io.write(line)
if isinstance(output_io, io.BytesIO):
2021-05-03 11:45:43 -04:00
output = str(output_io.getvalue(), encoding="utf8", errors="surrogateescape")
else:
2021-05-03 11:45:43 -04:00
output = ""
2020-06-24 16:43:33 +01:00
if return_code != 0:
raise subprocess.CalledProcessError(return_code, args, output)
2020-06-24 16:43:33 +01:00
return output
2020-06-24 16:43:33 +01:00
def get_environment(self) -> Dict[str, str]:
2021-04-30 17:56:34 -04:00
env = json.loads(
self.call(
[
self.UTILITY_PYTHON,
2021-05-03 11:45:43 -04:00
"-c",
"import sys, json, os; json.dump(os.environ.copy(), sys.stdout)",
2021-04-30 17:56:34 -04:00
],
capture_output=True,
)
)
return cast(Dict[str, str], env)
2020-06-24 16:43:33 +01:00
def environment_executor(self, command: List[str], environment: Dict[str, str]) -> str:
2020-06-24 16:43:33 +01:00
# used as an EnvironmentExecutor to evaluate commands and capture output
return self.call(command, env=environment, capture_output=True)
2020-07-10 13:04:50 +01:00
2022-06-16 18:11:59 -04:00
def debug_info(self) -> str:
if self.engine == "podman":
command = f"{self.engine} info --debug"
2022-06-16 18:11:59 -04:00
else:
command = f"{self.engine} info"
2022-06-16 18:11:59 -04:00
completed = subprocess.run(
command,
shell=True,
check=True,
cwd=self.cwd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
output = str(completed.stdout, encoding="utf8", errors="surrogateescape")
return output
2020-07-10 13:04:50 +01:00
def shell_quote(path: PurePath) -> str:
2022-06-26 17:10:02 -04:00
return shlex.quote(os.fspath(path))