Files
cibuildwheel/cibuildwheel/docker_container.py
T

243 lines
7.7 KiB
Python
Raw Normal View History

2020-06-24 16:43:33 +01:00
import io
import json
import os
2020-06-24 16:43:33 +01:00
import shlex
import subprocess
import sys
import uuid
from pathlib import Path, PurePath
from types import TracebackType
2021-01-06 13:50:58 -05:00
from typing import IO, Dict, List, Optional, Sequence, Type, cast
from .typing import PathOrStr, PopenBytes
2020-06-24 16:43:33 +01:00
class DockerContainer:
2021-05-03 11:45:43 -04:00
"""
2020-06-24 16:43:33 +01:00
An object that represents a running Docker container.
Intended for use as a context manager e.g.
2021-10-12 02:05:47 +01:00
`with DockerContainer(docker_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.
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__(
2021-10-12 02:05:47 +01:00
self, *, docker_image: str, simulate_32_bit: bool = False, cwd: Optional[PathOrStr] = None
2021-04-30 17:56:34 -04:00
):
2021-01-17 13:08:55 -05:00
if not docker_image:
raise ValueError("Must have a non-empty docker image to run.")
2020-06-24 16:43:33 +01:00
self.docker_image = docker_image
self.simulate_32_bit = simulate_32_bit
2020-07-20 01:04:26 +02:00
self.cwd = cwd
self.name: Optional[str] = None
2020-06-24 16:43:33 +01:00
2021-05-03 11:45:43 -04:00
def __enter__(self) -> "DockerContainer":
self.name = f"cibuildwheel-{uuid.uuid4()}"
cwd_args = ["-w", str(self.cwd)] if self.cwd else []
shell_args = ["linux32", "/bin/bash"] if self.simulate_32_bit else ["/bin/bash"]
2020-06-24 16:43:33 +01:00
subprocess.run(
[
2021-05-03 11:45:43 -04:00
"docker",
"create",
"--env=CIBUILDWHEEL",
f"--name={self.name}",
"--interactive",
"--volume=/:/host", # ignored on CircleCI
2020-07-20 01:04:26 +02:00
*cwd_args,
self.docker_image,
2021-04-30 17:56:34 -04:00
*shell_args,
2020-06-24 16:43:33 +01:00
],
check=True,
)
2020-06-26 10:59:47 +01:00
self.process = subprocess.Popen(
2020-06-24 16:43:33 +01:00
[
2021-05-03 11:45:43 -04:00
"docker",
"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
2021-05-03 11:45:43 -04:00
self.call(["/bin/true"])
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:
2020-06-24 16:43:33 +01:00
self.bash_stdin.close()
self.process.terminate()
self.process.wait()
assert isinstance(self.name, str)
2021-05-03 11:45:43 -04:00
subprocess.run(["docker", "rm", "--force", "-v", self.name], stdout=subprocess.DEVNULL)
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(
2021-05-03 11:45:43 -04:00
f"tar cf - . | docker exec -i {self.name} tar -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:
subprocess.run(
2020-07-10 13:04:50 +01:00
f'cat {shell_quote(from_path)} | docker exec -i {self.name} sh -c "cat > {shell_quote(to_path)}"',
2020-06-24 16:43:33 +01:00
shell=True,
2021-04-30 17:56:34 -04:00
check=True,
)
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)
subprocess.run(
2021-05-03 11:45:43 -04:00
f"docker exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -",
2020-06-24 16:43:33 +01:00
shell=True,
check=True,
2021-04-30 17:56:34 -04:00
cwd=to_path,
2020-06-24 16:43:33 +01:00
)
def glob(self, path: PurePath, pattern: str) -> List[PurePath]:
glob_pattern = os.path.join(str(path), pattern)
2021-04-30 17:56:34 -04:00
path_strs = json.loads(
self.call(
[
self.UTILITY_PYTHON,
2021-05-03 11:45:43 -04:00
"-c",
f"import sys, json, glob; json.dump(glob.glob({glob_pattern!r}), sys.stdout)",
2021-04-30 17:56:34 -04:00
],
capture_output=True,
)
)
2020-06-24 16:43:33 +01:00
return [PurePath(p) for p in path_strs]
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
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 returncode 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 returncode decimals
)
2021-04-29 20:21:42 -04:00
# fmt: on
2021-04-30 17:56:34 -04:00
returncode_str = line[footer_offset : footer_offset + 4]
2020-06-24 16:43:33 +01:00
returncode = int(returncode_str)
# 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 returncode != 0:
raise subprocess.CalledProcessError(returncode, args, output)
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
def shell_quote(path: PurePath) -> str:
return shlex.quote(str(path))