Skip to content

Commit 2192bc3

Browse files
committed
Add Docker Adapter
1 parent d334756 commit 2192bc3

File tree

8 files changed

+280
-6
lines changed

8 files changed

+280
-6
lines changed

.github/workflows/CI.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
6969
- name: Run Tests
7070
run: |
71-
pytest tests -m "not server_api and not slow"
71+
pytest tests -m "not server_api and not docker and not slow"
7272
7373
build-release:
7474
name: Build and Upload Release

cadetrdm/batch_running/case.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def _get_results_branch(self):
155155
print("No matching results were found for these options and study version.")
156156
return None
157157

158-
def run_study(self, force=False) -> bool:
158+
def run_study(self, force=False, container_adapter: "DockerAdapter" = None) -> bool:
159159
"""
160160
Run specified study commands in the given repository.
161161
@@ -177,15 +177,18 @@ def run_study(self, force=False) -> bool:
177177
print(f"{self.study.path} has already been computed with these options. Skipping...")
178178
return True
179179

180-
if not self.can_run_study:
180+
if container_adapter is None and self.can_run_study is False:
181181
print(f"Current environment does not match required environment. Skipping...")
182182
self.status = 'failed'
183183
return False
184184

185185
try:
186186
self.status = 'running'
187187

188-
self.study.module.main(self.options, str(self.study.path))
188+
if container_adapter is not None:
189+
container_adapter.run_case(self)
190+
else:
191+
self.study.module.main(self.options, str(self.study.path))
189192

190193
print("Command execution successful.")
191194
self.status = 'finished'

cadetrdm/docker/Dockerfile_template

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# syntax=docker/dockerfile:1
2+
3+
# Comments are provided throughout this file to help you get started.
4+
# If you need more help, visit the Dockerfile reference guide at
5+
# https://docs.docker.com/go/dockerfile-reference/
6+
7+
# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7
8+
9+
ARG CONDA_VERSION=24.11.3
10+
FROM condaforge/miniforge3:${CONDA_VERSION}-0 AS base
11+
12+
# Prevents Python from writing pyc files.
13+
ENV PYTHONDONTWRITEBYTECODE=1
14+
15+
# Keeps Python from buffering stdout and stderr to avoid situations where
16+
# the application crashes without emitting any logs due to buffering.
17+
ENV PYTHONUNBUFFERED=1
18+
19+
WORKDIR /rdm_workdir
20+
21+
USER root
22+
23+
# Prevents interactive prompts during apt-get
24+
ARG DEBIAN_FRONTEND=noninteractive
25+
26+
RUN apt-get update && apt-get install -y git git-lfs ssh && \
27+
apt-get clean && rm -rf /var/lib/apt/lists/*
28+
29+
COPY environment.yml /tmp/environment.yml
30+
31+
RUN conda env update -n base --file /tmp/environment.yml

cadetrdm/docker/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
from .containerAdapter import ContainerAdapter
3+
from .dockerAdapter import DockerAdapter

cadetrdm/docker/containerAdapter.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from abc import abstractmethod
2+
3+
4+
class ContainerAdapter:
5+
pass
6+
7+
@abstractmethod
8+
def run_case(self, case, command):
9+
return

cadetrdm/docker/dockerAdapter.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import os
2+
import subprocess
3+
import tempfile
4+
from pathlib import Path
5+
6+
import docker
7+
from docker.types import Mount
8+
from docker.models.images import Image
9+
10+
from cadetrdm.docker import ContainerAdapter
11+
12+
13+
class DockerAdapter(ContainerAdapter):
14+
15+
def __init__(self):
16+
self.client = docker.from_env()
17+
self.image = None
18+
19+
def run_case(self, case: "Case", command: str = None):
20+
21+
if case.environment is not None:
22+
self._update_Dockerfile_with_env_reqs(case)
23+
24+
if self.image is None:
25+
image = self._build_image(case)
26+
else:
27+
image = self.image
28+
29+
container_tmp_filename = "/tmp/options.json"
30+
options_tmp_filename = self._dump_options(case)
31+
32+
full_command = self._prepare_command(
33+
case=case,
34+
command=command,
35+
container_tmp_filename=container_tmp_filename
36+
)
37+
38+
full_log = self._run_command(
39+
container_tmp_filename=container_tmp_filename,
40+
full_command=full_command,
41+
image=image,
42+
options_tmp_filename=options_tmp_filename
43+
)
44+
45+
return full_log
46+
47+
def _run_command(self, container_tmp_filename, full_command, image, options_tmp_filename):
48+
49+
ssh_location = Path.home() / ".ssh"
50+
if not ssh_location.exists():
51+
raise FileNotFoundError("No ssh folder found. Please report this on GitHub/CADET/CADET-RDM")
52+
53+
container = self.client.containers.run(
54+
image=image,
55+
command=full_command,
56+
volumes={
57+
f"{Path.home()}/.ssh": {'bind': "/root/.ssh_host_os", 'mode': "ro"},
58+
options_tmp_filename.absolute().as_posix(): {'bind': container_tmp_filename, 'mode': 'ro'}
59+
},
60+
detach=True,
61+
remove=True
62+
)
63+
64+
full_log = []
65+
# Step 2: Attach to the container's logsu
66+
for log in container.logs(stream=True):
67+
full_log.append(log.decode("utf-8"))
68+
print(log.decode("utf-8"), end="")
69+
# Wait for the container to finish execution
70+
container.wait()
71+
print("Done.")
72+
73+
return full_log
74+
75+
def _prepare_command(self, case, command, container_tmp_filename):
76+
# ensure ssh in the container knows where to look for known_hosts and that .ssh/config is read-only
77+
command_ssh = 'cp -r /root/.ssh_host_os /root/.ssh && chmod 600 /root/.ssh/*'
78+
79+
# copy over git config
80+
git_config_list = subprocess.check_output("git config --list --show-origin --global").decode().split("\n")
81+
git_config = {
82+
"user.name": None,
83+
"user.email": None,
84+
}
85+
for line in git_config_list:
86+
for key in git_config.keys():
87+
if key in line:
88+
value = line.split("=")[-1]
89+
# print(value)
90+
git_config[key] = value
91+
92+
git_commands = [f'git config --global {key} "{value}"' for key, value in git_config.items()]
93+
94+
# pull the study from the URL into a "study" folder
95+
command_pull = f"rdm clone {case.study.url} study"
96+
# cd into the "study" folder
97+
command_cd = "cd study"
98+
# run main.py with the options, assuming main.py lies within a sub-folder with the same name as the study.name
99+
if command is None:
100+
command_python = f"python {case.study.name}/main.py {container_tmp_filename}"
101+
else:
102+
command_python = command
103+
104+
commands = git_commands + [command_ssh, command_pull, command_cd, command_python]
105+
full_command = 'bash -c "' + ' && '.join(commands) + '"'
106+
return full_command
107+
108+
def _dump_options(self, case):
109+
tmp_filename = Path("tmp/" + next(tempfile._get_candidate_names()) + ".json")
110+
case.options.dump_json_file(tmp_filename)
111+
return tmp_filename
112+
113+
def _build_image(self, case) -> Image:
114+
cwd = os.getcwd()
115+
with open(case.study.path / "Dockerfile", "rb") as dockerfile:
116+
os.chdir(case.study.path.as_posix())
117+
118+
image, logs = self.client.images.build(
119+
path=case.study.path.as_posix(),
120+
# fileobj=dockerfile, # A file object to use as the Dockerfile.
121+
tag=case.study.name + ":" + case.name[:10], # A tag to add to the final image
122+
quiet=False, # Whether to return the status
123+
pull=True, # Downloads any updates to the FROM image in Dockerfiles
124+
125+
)
126+
if case.options.debug:
127+
for log in logs:
128+
print(log)
129+
os.chdir(cwd)
130+
return image
131+
132+
def pull_image(self, repository, tag=None, all_tags=False, **kwargs):
133+
self.image = self.client.images.pull(
134+
repository=repository,
135+
tag=tag,
136+
all_tags=all_tags,
137+
**kwargs
138+
)
139+
140+
def _push_image(self, repository, tag=None, **kwargs):
141+
self.client.images.push(
142+
repository=repository,
143+
tag=tag,
144+
**kwargs
145+
)
146+
147+
def _tag_image(self, image: Image, repository, tag=None, **kwargs) -> Image:
148+
"""
149+
Tag this image into a repository. Similar to the ``docker tag``
150+
command.
151+
152+
Args:
153+
repository (str): The repository to set for the tag
154+
tag (str): The tag name
155+
force (bool): Force
156+
157+
Raises:
158+
:py:class:`docker.errors.APIError`
159+
If the server returns an error.
160+
161+
Returns:
162+
(bool): ``True`` if successful
163+
"""
164+
image.tag(repository=repository, tag=tag, **kwargs)
165+
return image
166+
167+
def build_and_push_image(self, case, repository, tag=None, **kwargs):
168+
image = self._build_image(case)
169+
image = self._tag_image(image, repository, tag, **kwargs)
170+
self._push_image(repository, tag, **kwargs)
171+
172+
def _update_Dockerfile_with_env_reqs(self, case):
173+
case.study._reset_hard_to_head(force_entry=True)
174+
175+
dockerfile = Path(case.study.path) / "Dockerfile"
176+
conda, pip = case.environment.prepare_install_instructions()
177+
# We need to switch to root to update conda packages and to the $CONDA_USER to update pip packages
178+
install_command = "\n"
179+
if len(conda) > 0:
180+
install_command += f"RUN {conda}\n"
181+
if len(pip) > 0:
182+
install_command += f"RUN {pip}\n"
183+
install_command += f"RUN pip install --force-reinstall --no-deps {pip.split('pip install')[-1]}\n"
184+
185+
with open(dockerfile, "a") as handle:
186+
handle.write(install_command)
187+
188+
def __del__(self):
189+
self.client.close()

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies = [
3232
"numpy",
3333
"pyyaml",
3434
"semantic-version",
35+
"docker" # python-docker interface
3536
]
3637

3738
[project.scripts]
@@ -73,11 +74,11 @@ docs = [
7374
"myst-nb>=0.17.1",
7475
]
7576

76-
7777
[tool.pytest.ini_options]
7878
markers = [
7979
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
80-
"server_api: marks tests as using the GitLab/GitHub API"
80+
"server_api: marks tests as using the GitLab/GitHub API",
81+
"docker: marks tests as using the Docker API"
8182
]
8283

8384
[tool.setuptools.dynamic]

tests/test_docker.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from pathlib import Path
2+
import pytest
3+
4+
from cadetrdm import Study, Options, Environment, Case
5+
from cadetrdm.docker import DockerAdapter
6+
7+
8+
@pytest.mark.docker
9+
def test_run_dockered():
10+
WORK_DIR = Path.cwd() / "tmp"
11+
WORK_DIR.mkdir(parents=True, exist_ok=True)
12+
13+
rdm_example = Study(
14+
WORK_DIR / 'template',
15+
"[email protected]:ronald-jaepel/rdm_testing_template.git",
16+
)
17+
18+
options = Options()
19+
options.debug = False
20+
options.push = False
21+
options.commit_message = 'Trying out new things'
22+
options.optimizer_options = {
23+
"optimizer": "U_NSGA3",
24+
"pop_size": 2,
25+
"n_cores": 2,
26+
"n_max_gen": 1,
27+
}
28+
29+
matching_environment = Environment(
30+
pip_packages={
31+
"cadet-rdm": "git+https://github.com/cadet/CADET-RDM.git@3e073dd85c5e54d95422c0cdcc1190d80da9e138"
32+
}
33+
)
34+
35+
case = Case(study=rdm_example, options=options, environment=matching_environment)
36+
docker_adapter = DockerAdapter()
37+
has_run_study = case.run_study(container_adapter=docker_adapter, force=True)
38+
assert has_run_study

0 commit comments

Comments
 (0)