Skip to content

Commit a0e9b31

Browse files
authored
Bundle code from container-utils (#300)
[container-utils](https://github.com/adamrehn/container-utils) 1. Isn't used by anyone except us 2. Isn't actively maintained 3. Forces `setuptools`/`wheel`/`twine` deps upon users 4. Overall, doesn't look like a valuable asset in itself This commit brings code from `container-utils` into ue4-docker repo and drops dependency on `container-utils`.
1 parent 6af5294 commit a0e9b31

File tree

5 files changed

+175
-20
lines changed

5 files changed

+175
-20
lines changed

pyproject.toml

-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ classifiers = [
2222
]
2323
dependencies = [
2424
"colorama",
25-
"container-utils",
2625
"docker>=3.0.0",
2726
"humanfriendly",
2827
"importlib-metadata>=1.0;python_version<'3.8'",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import contextlib
2+
import io
3+
import logging
4+
import os
5+
import shutil
6+
import sys
7+
import tempfile
8+
9+
import docker
10+
from docker.models.containers import Container
11+
12+
13+
class ContainerUtils(object):
14+
"""
15+
Provides functionality related to Docker containers
16+
"""
17+
18+
@staticmethod
19+
@contextlib.contextmanager
20+
def automatically_stop(container: Container, timeout: int = 1):
21+
"""
22+
Context manager to automatically stop a container returned by `ContainerUtils.start_for_exec()`
23+
"""
24+
try:
25+
yield container
26+
finally:
27+
logging.info("Stopping Docker container {}...".format(container.short_id))
28+
container.stop(timeout=timeout)
29+
30+
@staticmethod
31+
def copy_from_host(
32+
container: Container, host_path: str, container_path: str
33+
) -> None:
34+
"""
35+
Copies a file or directory from the host system to a container returned by `ContainerUtils.start_for_exec()`.
36+
37+
`host_path` is the absolute path to the file or directory on the host system.
38+
39+
`container_path` is the absolute path to the directory in the container where the copied file(s) will be placed.
40+
"""
41+
42+
# If the host path denotes a file rather than a directory, copy it to a temporary directory
43+
# (If the host path is a directory then we create a no-op context manager to use in our `with` statement below)
44+
tempDir = contextlib.suppress()
45+
if os.path.isfile(host_path):
46+
tempDir = tempfile.TemporaryDirectory()
47+
shutil.copy2(
48+
host_path, os.path.join(tempDir.name, os.path.basename(host_path))
49+
)
50+
host_path = tempDir.name
51+
52+
# Automatically delete the temporary directory if we created one
53+
with tempDir:
54+
# Create a temporary file to hold the .tar archive data
55+
with tempfile.NamedTemporaryFile(
56+
suffix=".tar", delete=False
57+
) as tempArchive:
58+
# Add the data from the host system to the temporary archive
59+
tempArchive.close()
60+
archiveName = os.path.splitext(tempArchive.name)[0]
61+
shutil.make_archive(archiveName, "tar", host_path)
62+
63+
# Copy the data from the temporary archive to the container
64+
with open(tempArchive.name, "rb") as archive:
65+
container.put_archive(container_path, archive.read())
66+
67+
# Remove the temporary archive
68+
os.unlink(tempArchive.name)
69+
70+
@staticmethod
71+
def exec(container: Container, command: [str], capture: bool = False, **kwargs):
72+
"""
73+
Executes a command in a container returned by `ContainerUtils.start_for_exec()` and streams or captures the output
74+
"""
75+
76+
# Determine if we are capturing the output or printing it
77+
stdoutDest = io.StringIO() if capture else sys.stdout
78+
stderrDest = io.StringIO() if capture else sys.stderr
79+
80+
# Attempt to start the command
81+
details = container.client.api.exec_create(container.id, command, **kwargs)
82+
output = container.client.api.exec_start(details["Id"], stream=True, demux=True)
83+
84+
# Stream the output
85+
for chunk in output:
86+
# Isolate the stdout and stderr chunks
87+
stdout, stderr = chunk
88+
89+
# Capture/print the stderr data if we have any
90+
if stderr is not None:
91+
print(stderr.decode("utf-8"), end="", flush=True, file=stderrDest)
92+
93+
# Capture/print the stdout data if we have any
94+
if stdout is not None:
95+
print(stdout.decode("utf-8"), end="", flush=True, file=stdoutDest)
96+
97+
# Determine if the command succeeded
98+
capturedOutput = (
99+
(stdoutDest.getvalue(), stderrDest.getvalue()) if capture else None
100+
)
101+
result = container.client.api.exec_inspect(details["Id"])["ExitCode"]
102+
if result != 0:
103+
container.stop()
104+
raise RuntimeError(
105+
"Failed to run command {} in container. Process returned exit code {} with output {}.".format(
106+
command,
107+
result,
108+
capturedOutput if capture else "printed above",
109+
)
110+
)
111+
112+
# If we captured the output then return it
113+
return capturedOutput
114+
115+
@staticmethod
116+
def start_for_exec(
117+
client: docker.DockerClient, image: str, platform: str, **kwargs
118+
) -> Container:
119+
"""
120+
Starts a container in a detached state using a command that will block indefinitely
121+
and returns the container handle. The handle can then be used to execute commands
122+
inside the container. The container will be removed automatically when it is stopped,
123+
but it will need to be stopped manually by calling `ContainerUtils.stop()`.
124+
"""
125+
command = (
126+
["timeout", "/t", "99999", "/nobreak"]
127+
if platform == "windows"
128+
else ["bash", "-c", "sleep infinity"]
129+
)
130+
return client.containers.run(
131+
image,
132+
command,
133+
stdin_open=platform == "windows",
134+
tty=platform == "windows",
135+
detach=True,
136+
remove=True,
137+
**kwargs,
138+
)

src/ue4docker/infrastructure/DockerUtils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def listImages(tagFilter=None, filters={}, all=False):
166166
return images
167167

168168
@staticmethod
169-
def exec(container, command, **kwargs):
169+
def exec(container, command: [str], **kwargs):
170170
"""
171171
Executes a command in a container returned by `DockerUtils.start()` and returns the output
172172
"""
@@ -182,7 +182,7 @@ def exec(container, command, **kwargs):
182182
return output
183183

184184
@staticmethod
185-
def execMultiple(container, commands, **kwargs):
185+
def execMultiple(container, commands: [[str]], **kwargs):
186186
"""
187187
Executes multiple commands in a container returned by `DockerUtils.start()`
188188
"""

src/ue4docker/infrastructure/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .BuildConfiguration import BuildConfiguration
2+
from .ContainerUtils import ContainerUtils
23
from .CredentialEndpoint import CredentialEndpoint
34
from .DarwinUtils import DarwinUtils
45
from .DockerUtils import DockerUtils

src/ue4docker/test.py

+34-17
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
from .infrastructure import DockerUtils, GlobalConfiguration, Logger
2-
from container_utils import ContainerUtils, ImageUtils
3-
import docker, os, platform, sys
1+
import ntpath
2+
import os
3+
import posixpath
4+
import sys
5+
6+
import docker
7+
from docker.errors import ImageNotFound
8+
9+
from .infrastructure import (
10+
ContainerUtils,
11+
GlobalConfiguration,
12+
Logger,
13+
)
414

515

616
def test():
@@ -14,45 +24,52 @@ def test():
1424
if len(sys.argv) > 1 and sys.argv[1].strip("-") not in ["h", "help"]:
1525
# Verify that the specified container image exists
1626
tag = sys.argv[1]
17-
image = GlobalConfiguration.resolveTag(
27+
image_name = GlobalConfiguration.resolveTag(
1828
"ue4-full:{}".format(tag) if ":" not in tag else tag
1929
)
20-
if DockerUtils.exists(image) == False:
30+
31+
try:
32+
image = client.images.get(image_name)
33+
except ImageNotFound:
2134
logger.error(
2235
'Error: the specified container image "{}" does not exist.'.format(
23-
image
36+
image_name
2437
)
2538
)
2639
sys.exit(1)
2740

2841
# Use process isolation mode when testing Windows containers, since running Hyper-V containers don't currently support manipulating the filesystem
29-
platform = ImageUtils.image_platform(client, image)
42+
platform = image.attrs["Os"]
3043
isolation = "process" if platform == "windows" else None
3144

3245
# Start a container to run our tests in, automatically stopping and removing the container when we finish
3346
logger.action(
34-
'Starting a container using the "{}" image...'.format(image), False
47+
'Starting a container using the "{}" image...'.format(image_name), False
48+
)
49+
container = ContainerUtils.start_for_exec(
50+
client, image_name, platform, isolation=isolation
3551
)
36-
container = ContainerUtils.start_for_exec(client, image, isolation=isolation)
3752
with ContainerUtils.automatically_stop(container):
3853
# Create the workspace directory in the container
39-
workspaceDir = ContainerUtils.workspace_dir(container)
54+
workspaceDir = (
55+
"C:\\workspace" if platform == "windows" else "/tmp/workspace"
56+
)
57+
shell_prefix = (
58+
["cmd", "/S", "/C"] if platform == "windows" else ["bash", "-c"]
59+
)
60+
4061
ContainerUtils.exec(
4162
container,
42-
ContainerUtils.shell_prefix(container) + ["mkdir " + workspaceDir],
63+
shell_prefix + ["mkdir " + workspaceDir],
4364
)
4465

4566
# Copy our test scripts into the container
4667
testDir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tests")
4768
ContainerUtils.copy_from_host(container, testDir, workspaceDir)
4869

4970
# Create a harness to invoke individual tests
50-
containerPath = ContainerUtils.path(container)
51-
pythonCommand = (
52-
"python"
53-
if ContainerUtils.container_platform(container) == "windows"
54-
else "python3"
55-
)
71+
containerPath = ntpath if platform == "windows" else posixpath
72+
pythonCommand = "python" if platform == "windows" else "python3"
5673

5774
def runTest(script):
5875
logger.action('Running test "{}"...'.format(script), False)

0 commit comments

Comments
 (0)