Skip to content

Commit 3807d67

Browse files
authored
Merge pull request #65 from ImogenBits/local_docker
Isolate docker build environments
2 parents 7b00a74 + 1df7a6e commit 3807d67

File tree

4 files changed

+97
-42
lines changed

4 files changed

+97
-42
lines changed

algobattle/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
__title__ = 'Algorithmic Battle'
2-
__version__ = '3.0.2'
2+
__version__ = '3.1.0'
33
__author__ = 'Jan Dreier, Henri Lotze'
44
__license__ = 'MIT'

algobattle/match.py

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Match class, provides functionality for setting up and executing battles between given teams."""
2+
from pathlib import Path
23
import subprocess
34
import os
45

@@ -9,7 +10,7 @@
910
import algobattle.sighandler as sigh
1011
from algobattle.team import Team
1112
from algobattle.problem import Problem
12-
from algobattle.util import run_subprocess, update_nested_dict
13+
from algobattle.util import build_image, run_subprocess, update_nested_dict
1314
from algobattle.subject import Subject
1415
from algobattle.observer import Observer
1516
from algobattle.battle_wrappers.averaged import Averaged
@@ -25,9 +26,16 @@ class Match(Subject):
2526
generating_team = None
2627
solving_team = None
2728
battle_wrapper = None
29+
creationflags = 0
2830

2931
def __init__(self, problem: Problem, config_path: str, teams: list,
30-
runtime_overhead=0, approximation_ratio=1.0, cache_docker_containers=True) -> None:
32+
runtime_overhead=0, approximation_ratio=1.0,
33+
cache_docker_containers=True, unsafe_build: bool = False) -> None:
34+
35+
if os.name != 'posix':
36+
self.creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
37+
else:
38+
self.creationflags = 0
3139

3240
config = configparser.ConfigParser()
3341
logger.debug('Using additional configuration options from file "%s".', config_path)
@@ -43,7 +51,7 @@ def __init__(self, problem: Problem, config_path: str, teams: list,
4351
self.config = config
4452
self.approximation_ratio = approximation_ratio
4553

46-
self.build_successful = self._build(teams, cache_docker_containers)
54+
self.build_successful = self._build(teams, cache_docker_containers, unsafe_build)
4755

4856
if approximation_ratio != 1.0 and not problem.approximable:
4957
logger.error('The given problem is not approximable and can only be run with an approximation ratio of 1.0!')
@@ -92,11 +100,8 @@ def wrapper(self, *args, **kwargs):
92100
def docker_running(function: Callable) -> Callable:
93101
"""Ensure that internal methods are only callable if docker is running."""
94102
def wrapper(self, *args, **kwargs):
95-
creationflags = 0
96-
if os.name != 'posix':
97-
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
98103
docker_running = subprocess.Popen(['docker', 'info'], stdout=subprocess.PIPE,
99-
stderr=subprocess.PIPE, creationflags=creationflags)
104+
stderr=subprocess.PIPE, creationflags=self.creationflags)
100105
_ = docker_running.communicate()
101106
if docker_running.returncode:
102107
logger.error('Could not connect to the docker daemon. Is docker running?')
@@ -132,7 +137,7 @@ def update_match_data(self, new_data: dict) -> bool:
132137
return True
133138

134139
@docker_running
135-
def _build(self, teams: list, cache_docker_containers=True) -> bool:
140+
def _build(self, teams: list, cache_docker_containers=True, unsafe_build: bool = False) -> bool:
136141
"""Build docker containers for the given generators and solvers of each team.
137142
138143
Any team for which either the generator or solver does not build successfully
@@ -144,6 +149,8 @@ def _build(self, teams: list, cache_docker_containers=True) -> bool:
144149
List of Team objects.
145150
cache_docker_containers : bool
146151
Flag indicating whether to cache built docker containers.
152+
unsafe_build: bool
153+
If set, images are not removed after building, risking exposure to build process of other teams.
147154
148155
Returns
149156
-------
@@ -169,36 +176,31 @@ def _build(self, teams: list, cache_docker_containers=True) -> bool:
169176

170177
self.single_player = (len(teams) == 1)
171178

179+
image_archives: list = []
172180
for team in teams:
173181
build_commands = []
174-
build_commands.append(base_build_command + ["solver-" + str(team.name), team.solver_path])
175-
build_commands.append(base_build_command + ["generator-" + str(team.name), team.generator_path])
182+
build_commands.append(("solver-" + str(team.name), team.solver_path))
183+
build_commands.append(("generator-" + str(team.name), team.generator_path))
176184

177185
build_successful = True
178-
for command in build_commands:
179-
logger.debug('Building docker container with the following command: {}'.format(command))
180-
creationflags = 0
181-
if os.name != 'posix':
182-
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
183-
with subprocess.Popen(command, stdout=subprocess.PIPE,
184-
stderr=subprocess.PIPE, creationflags=creationflags) as process:
185-
try:
186-
output, _ = process.communicate(timeout=self.timeout_build)
187-
logger.debug(output.decode(errors="ignore"))
188-
except subprocess.TimeoutExpired:
189-
process.kill()
190-
process.wait()
191-
logger.error('Build process for {} ran into a timeout!'.format(command[5]))
192-
build_successful = False
193-
if process.returncode != 0:
194-
process.kill()
195-
process.wait()
196-
logger.error('Build process for {} failed!'.format(command[5]))
197-
build_successful = False
198-
if not build_successful:
199-
logger.error("Removing team {} as their containers did not build successfully.".format(team.name))
200-
self.team_names.remove(team.name)
201-
186+
for name, path in build_commands:
187+
logger.debug(f"Building docker container with the following command: {base_build_command} {name} {path}")
188+
build_successful = build_image(base_build_command, name, path, timeout=self.timeout_build)
189+
if not build_successful:
190+
logger.error("Removing team {} as their containers did not build successfully.".format(team.name))
191+
self.team_names.remove(team.name)
192+
if name.startswith("generator") and not unsafe_build:
193+
image_archives.pop().unlink()
194+
break
195+
elif not unsafe_build:
196+
path = Path(path) / f"{name}-archive.tar"
197+
subprocess.run(["docker", "save", name, "-o", str(path)], stdout=subprocess.PIPE)
198+
image_archives.append(path)
199+
subprocess.run(["docker", "image", "rm", "-f", name], stdout=subprocess.PIPE)
200+
201+
for path in image_archives:
202+
subprocess.run(["docker", "load", "-q", "-i", str(path)], stdout=subprocess.PIPE)
203+
path.unlink()
202204
return len(self.team_names) > 0
203205

204206
@build_successful
@@ -296,6 +298,9 @@ def run(self, battle_type: str = 'iterated', rounds: int = 5, iterated_cap: int
296298
self.solving_team = pair[1]
297299
self.battle_wrapper.wrapper(self, options)
298300

301+
for team in self.team_names:
302+
for role in ("generator", "solver"):
303+
subprocess.run(["docker", "image", "rm", "-f", f"{role}-{team}"], stdout=subprocess.PIPE)
299304
return self.match_data
300305

301306
@docker_running

algobattle/util.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Collection of utility functions."""
22
import os
33
import logging
4+
from pathlib import Path
45
import timeit
56
import subprocess
67
import importlib.util
@@ -13,7 +14,12 @@
1314
from algobattle.problem import Problem
1415

1516

16-
logger = logging.getLogger('algobattle.util')
17+
logger = logging.getLogger("algobattle.util")
18+
19+
20+
creationflags = 0
21+
if os.name != "posix":
22+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
1723

1824

1925
def import_problem_from_path(problem_path: str) -> Problem:
@@ -50,21 +56,24 @@ def measure_runtime_overhead() -> float:
5056
I/O overhead in seconds, rounded to two decimal places.
5157
"""
5258
problem = DelaytestProblem.Problem()
53-
config_path = os.path.join(os.path.dirname(os.path.abspath(algobattle.__file__)), 'config', 'config_delaytest.ini')
59+
config_path = os.path.join(os.path.dirname(os.path.abspath(algobattle.__file__)), "config", "config_delaytest.ini")
5460
delaytest_path = DelaytestProblem.__file__[:-12] # remove /__init__.py
55-
delaytest_team = algobattle.team.Team(0, delaytest_path + '/generator', delaytest_path + '/solver')
61+
delaytest_team = algobattle.team.Team(0, delaytest_path + "/generator", delaytest_path + "/solver")
5662

5763
match = algobattle.match.Match(problem, config_path, [delaytest_team])
5864

5965
if not match.build_successful:
60-
logger.warning('Building a match for the time tolerance calculation failed!')
66+
logger.warning("Building a match for the time tolerance calculation failed!")
6167
return 0
6268

6369
overheads = []
6470
for i in range(5):
6571
sigh.latest_running_docker_image = "generator0"
66-
_, timeout = run_subprocess(match.generator_base_run_command(match.space_generator) + ["generator0"],
67-
input=str(50 * i).encode(), timeout=match.timeout_generator)
72+
_, timeout = run_subprocess(
73+
match.generator_base_run_command(match.space_generator) + ["generator0"],
74+
input=str(50 * i).encode(),
75+
timeout=match.timeout_generator,
76+
)
6877
if not timeout:
6978
timeout = match.timeout_generator
7079
overheads.append(float(timeout))
@@ -126,6 +135,46 @@ def run_subprocess(run_command: list, input: bytes, timeout: float, suppress_out
126135
return raw_output, elapsed_time
127136

128137

138+
def build_image(base_cmd: list, name: str, path: Path, timeout: float) -> bool:
139+
"""Builds a docker image of the given name using the base_command.
140+
141+
Parameters
142+
----------
143+
base_cmd : list
144+
A list containing a shared prefix of a command.
145+
name : str
146+
The intended name (tag) of the image.
147+
path: Path
148+
Path to the image.
149+
timeout: float
150+
Timeout after which the build process is terminated preemptively.
151+
152+
Returns
153+
-------
154+
bool
155+
Determines whether an image with the given tag is now built and thus accessible.
156+
"""
157+
path = Path(path)
158+
build_successful = True
159+
with subprocess.Popen(
160+
base_cmd + [name, str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=creationflags
161+
) as process:
162+
try:
163+
output, _ = process.communicate(timeout=timeout)
164+
logger.debug(output.decode())
165+
except subprocess.TimeoutExpired:
166+
process.kill()
167+
process.wait()
168+
logger.error(f"Build process for {name} ran into a timeout!")
169+
build_successful = False
170+
if process.returncode != 0:
171+
process.kill()
172+
process.wait()
173+
logger.error(f"Build process for {name} failed!")
174+
build_successful = False
175+
return build_successful
176+
177+
129178
def update_nested_dict(current_dict, updates):
130179
"""Update a nested dictionary with new data recursively.
131180

scripts/battle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ if __name__ == "__main__":
9595
parser.add_option('--silent', dest='silent', action='store_true', help='Disable forking the logging output to stderr.')
9696
parser.add_option('--no_overhead_calculation', dest='no_overhead_calculation', action='store_true', help='If set, the program does not benchmark the I/O of the host system to calculate the runtime overhead when started.')
9797
parser.add_option('--ui', dest='display_ui', action='store_true', help='If set, the program sets the --silent option and displays a small ui on STDOUT that shows the progress of the battles.')
98+
parser.add_option('--unsafe_build', dest='unsafe_build', action='store_true', help='If set, the build processes of the docker containers will not be isolated from each other.')
9899

99100
(options, args) = parser.parse_args()
100101

@@ -140,7 +141,7 @@ if __name__ == "__main__":
140141
runtime_overhead = measure_runtime_overhead()
141142
logger.info('Maximal measured runtime overhead is at {} seconds. Adding this amount to the configured runtime.'.format(runtime_overhead))
142143

143-
match = Match(problem, options.config, teams, runtime_overhead=runtime_overhead, approximation_ratio=options.approximation_ratio)
144+
match = Match(problem, options.config, teams, runtime_overhead=runtime_overhead, approximation_ratio=options.approximation_ratio, unsafe_build=options.unsafe_build)
144145

145146
if not match.build_successful:
146147
logger.critical('Building the match object failed, exiting!')

0 commit comments

Comments
 (0)