2020-06-24 16:43:33 +01:00
|
|
|
import io
|
|
|
|
|
import json
|
2020-07-10 12:57:05 +01:00
|
|
|
import os
|
2020-06-24 16:43:33 +01:00
|
|
|
import shlex
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import uuid
|
|
|
|
|
from pathlib import Path, PurePath
|
2021-01-02 14:32:55 -05:00
|
|
|
from types import TracebackType
|
2021-01-06 13:50:58 -05:00
|
|
|
from typing import IO, Dict, List, Optional, Sequence, Type, cast
|
2021-01-02 14:32:55 -05:00
|
|
|
|
|
|
|
|
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
|
|
|
|
2021-01-02 14:32:55 -05:00
|
|
|
process: PopenBytes
|
2020-06-26 21:36:52 +01:00
|
|
|
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
|
2020-06-25 10:39:26 +01:00
|
|
|
self.simulate_32_bit = simulate_32_bit
|
2020-07-20 01:04:26 +02:00
|
|
|
self.cwd = cwd
|
2021-01-02 14:32:55 -05:00
|
|
|
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,
|
2020-06-25 10:39:26 +01:00
|
|
|
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",
|
2020-06-26 21:36:52 +01:00
|
|
|
self.name,
|
2020-06-24 16:43:33 +01:00
|
|
|
],
|
|
|
|
|
stdin=subprocess.PIPE,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
)
|
2020-06-26 21:36:52 +01:00
|
|
|
|
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
|
|
|
|
|
|
2021-01-02 14:32:55 -05:00
|
|
|
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:
|
2021-01-02 14:32:55 -05:00
|
|
|
|
2020-06-24 16:43:33 +01:00
|
|
|
self.bash_stdin.close()
|
|
|
|
|
self.process.terminate()
|
|
|
|
|
self.process.wait()
|
|
|
|
|
|
2021-01-02 14:32:55 -05:00
|
|
|
assert isinstance(self.name, str)
|
|
|
|
|
|
2021-05-03 11:45:43 -04:00
|
|
|
subprocess.run(["docker", "rm", "--force", "-v", self.name], stdout=subprocess.DEVNULL)
|
2020-06-26 21:36:52 +01:00
|
|
|
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
|
|
|
)
|
|
|
|
|
|
2020-07-10 12:57:05 +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]
|
|
|
|
|
|
2021-01-02 14:32:55 -05: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
|
|
|
|
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
|
|
|
|
2020-06-25 11:21:10 +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}
|
2020-06-25 11:21:10 +01:00
|
|
|
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:
|
2020-06-26 21:36:52 +01:00
|
|
|
output_io: IO[bytes] = io.BytesIO()
|
2020-06-24 16:43:33 +01:00
|
|
|
else:
|
2020-06-26 21:36:52 +01:00
|
|
|
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)
|
|
|
|
|
|
2020-06-26 21:36:52 +01:00
|
|
|
if isinstance(output_io, io.BytesIO):
|
2021-05-03 11:45:43 -04:00
|
|
|
output = str(output_io.getvalue(), encoding="utf8", errors="surrogateescape")
|
2020-06-26 21:36:52 +01:00
|
|
|
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)
|
|
|
|
|
|
2020-06-26 21:36:52 +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,
|
|
|
|
|
)
|
|
|
|
|
)
|
2021-01-02 14:32:55 -05:00
|
|
|
return cast(Dict[str, str], env)
|
2020-06-24 16:43:33 +01:00
|
|
|
|
2020-07-19 17:37:25 +02: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
|
2020-07-20 02:23:07 +02:00
|
|
|
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))
|