Skip to content

Commit 65ddfc9

Browse files
misterbrandonwalkerBrandon Walkermr-c
authored
singularity: support dockerFile (#1938)
* allow user to set temp dir with APPTAINER_TMPDIR * singularity build: automatically try fakeroot mode if proot is missing --------- Co-authored-by: Brandon Walker <[email protected]> Co-authored-by: Michael R. Crusoe <[email protected]>
1 parent e4b5d92 commit 65ddfc9

File tree

13 files changed

+193
-12
lines changed

13 files changed

+193
-12
lines changed

.github/workflows/ci-tests.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ jobs:
7777
key: mypy-${{ env.py-semver }}
7878

7979
- name: Test with tox
80-
run: tox
80+
run: APPTAINER_TMPDIR=${RUNNER_TEMP} tox
8181

8282
- name: Upload coverage to Codecov
8383
if: ${{ matrix.step == 'unit' }}
@@ -156,7 +156,7 @@ jobs:
156156
chmod a-w .
157157
158158
- name: run tests
159-
run: make test
159+
run: APPTAINER_TMPDIR=${RUNNER_TEMP} make test
160160

161161

162162
conformance_tests:

cwltool/singularity.py

+34-9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from typing import Callable, Dict, List, MutableMapping, Optional, Tuple, cast
1111

1212
from schema_salad.sourceline import SourceLine
13+
from spython.main import Client
14+
from spython.main.parse.parsers.docker import DockerParser
15+
from spython.main.parse.writers.singularity import SingularityWriter
1316

1417
from .builder import Builder
1518
from .context import RuntimeContext
@@ -140,6 +143,7 @@ def __init__(
140143
def get_image(
141144
dockerRequirement: Dict[str, str],
142145
pull_image: bool,
146+
tmp_outdir_prefix: str,
143147
force_pull: bool = False,
144148
) -> bool:
145149
"""
@@ -162,7 +166,35 @@ def get_image(
162166
elif is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
163167
cache_folder = os.environ["SINGULARITY_PULLFOLDER"]
164168

165-
if "dockerImageId" not in dockerRequirement and "dockerPull" in dockerRequirement:
169+
if "dockerFile" in dockerRequirement:
170+
if cache_folder is None: # if environment variables were not set
171+
cache_folder = create_tmp_dir(tmp_outdir_prefix)
172+
173+
absolute_path = os.path.abspath(cache_folder)
174+
dockerfile_path = os.path.join(absolute_path, "Dockerfile")
175+
singularityfile_path = dockerfile_path + ".def"
176+
# if you do not set APPTAINER_TMPDIR will crash
177+
# WARNING: 'nodev' mount option set on /tmp, it could be a
178+
# source of failure during build process
179+
# FATAL: Unable to create build: 'noexec' mount option set on
180+
# /tmp, temporary root filesystem won't be usable at this location
181+
with open(dockerfile_path, "w") as dfile:
182+
dfile.write(dockerRequirement["dockerFile"])
183+
184+
singularityfile = SingularityWriter(DockerParser(dockerfile_path).parse()).convert()
185+
with open(singularityfile_path, "w") as file:
186+
file.write(singularityfile)
187+
188+
os.environ["APPTAINER_TMPDIR"] = absolute_path
189+
singularity_options = ["--fakeroot"] if not shutil.which("proot") else []
190+
Client.build(
191+
recipe=singularityfile_path,
192+
build_folder=absolute_path,
193+
sudo=False,
194+
options=singularity_options,
195+
)
196+
found = True
197+
elif "dockerImageId" not in dockerRequirement and "dockerPull" in dockerRequirement:
166198
match = re.search(pattern=r"([a-z]*://)", string=dockerRequirement["dockerPull"])
167199
img_name = _normalize_image_id(dockerRequirement["dockerPull"])
168200
candidates.append(img_name)
@@ -243,13 +275,6 @@ def get_image(
243275
check_call(cmd, stdout=sys.stderr) # nosec
244276
found = True
245277

246-
elif "dockerFile" in dockerRequirement:
247-
raise SourceLine(
248-
dockerRequirement, "dockerFile", WorkflowException, debug
249-
).makeError(
250-
"dockerFile is not currently supported when using the "
251-
"Singularity runtime for Docker containers."
252-
)
253278
elif "dockerLoad" in dockerRequirement:
254279
if is_version_3_1_or_newer():
255280
if "dockerImageId" in dockerRequirement:
@@ -298,7 +323,7 @@ def get_from_requirements(
298323
if not bool(shutil.which("singularity")):
299324
raise WorkflowException("singularity executable is not available")
300325

301-
if not self.get_image(cast(Dict[str, str], r), pull_image, force_pull):
326+
if not self.get_image(cast(Dict[str, str], r), pull_image, tmp_outdir_prefix, force_pull):
302327
raise WorkflowException("Container image {} not found".format(r["dockerImageId"]))
303328

304329
return os.path.abspath(cast(str, r["dockerImageId"]))

mypy-stubs/spython/main/__init__.pyi

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from typing import Iterator, Optional
2+
3+
from .base import Client as _BaseClient
4+
from .build import build as base_build
5+
6+
class _Client(_BaseClient):
7+
build = base_build
8+
9+
Client = _Client()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class Client:
2+
def __init__(self) -> None: ...
3+
def version(self) -> str: ...

mypy-stubs/spython/main/build.pyi

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Iterator, Optional
2+
3+
from .base import Client
4+
5+
def build(
6+
self: Client,
7+
recipe: Optional[str] = ...,
8+
image: Optional[str] = ...,
9+
isolated: Optional[bool] = ...,
10+
sandbox: Optional[bool] = ...,
11+
writable: Optional[bool] = ...,
12+
build_folder: Optional[str] = ...,
13+
robot_name: Optional[bool] = ...,
14+
ext: Optional[str] = ...,
15+
sudo: Optional[bool] = ...,
16+
stream: Optional[bool] = ...,
17+
force: Optional[bool] = ...,
18+
options: Optional[list[str]] | None = ...,
19+
quiet: Optional[bool] = ...,
20+
return_result: Optional[bool] = ...,
21+
sudo_options: Optional[str | list[str]] = ...,
22+
singularity_options: Optional[list[str]] = ...,
23+
) -> tuple[str, Iterator[str]]: ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import abc
2+
3+
from ..recipe import Recipe
4+
5+
class ParserBase(metaclass=abc.ABCMeta):
6+
filename: str
7+
lines: list[str]
8+
args: dict[str, str]
9+
active_layer: str
10+
active_layer_num: int
11+
recipe: dict[str, Recipe]
12+
def __init__(self, filename: str, load: bool = ...) -> None: ...
13+
@abc.abstractmethod
14+
def parse(self) -> dict[str, Recipe]: ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from ..recipe import Recipe
2+
from .base import ParserBase as ParserBase
3+
4+
class DockerParser(ParserBase):
5+
name: str
6+
def __init__(self, filename: str = ..., load: bool = ...) -> None: ...
7+
def parse(self) -> dict[str, Recipe]: ...
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from typing import Optional
2+
3+
class Recipe:
4+
cmd: Optional[str]
5+
comments: list[str]
6+
entrypoint: Optional[str]
7+
environ: list[str]
8+
files: list[str]
9+
layer_files: dict[str, str]
10+
install: list[str]
11+
labels: list[str]
12+
ports: list[str]
13+
test: Optional[str]
14+
volumes: list[str]
15+
workdir: Optional[str]
16+
layer: int
17+
fromHeader: Optional[str]
18+
source: Optional[Recipe]
19+
def __init__(self, recipe: Optional[Recipe] = ..., layer: int = ...) -> None: ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from ..recipe import Recipe
2+
3+
class WriterBase:
4+
recipe: dict[str, Recipe]
5+
def __init__(self, recipe: dict[str, Recipe] | None = ...) -> None: ...
6+
def write(self, output_file: str | None = ..., force: bool = ...) -> None: ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Optional
2+
3+
from ..recipe import Recipe
4+
from .base import WriterBase as WriterBase
5+
6+
class SingularityWriter(WriterBase):
7+
name: str
8+
def __init__(self, recipe: Optional[dict[str, Recipe]] = ...) -> None: ...
9+
def validate(self) -> None: ...
10+
def convert(self, runscript: str = ..., force: bool = ...) -> str: ...

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ pydot>=1.4.1
1212
argcomplete>=1.12.0
1313
pyparsing!=3.0.2 # breaks --print-dot (pydot) https://github.com/pyparsing/pyparsing/issues/319
1414
cwl-utils>=0.32
15+
spython>=0.3.0

tests/test_tmpdir.py

+63-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Test that all temporary directories respect the --tmpdir-prefix and --tmp-outdir-prefix options."""
2+
import os
23
import re
4+
import shutil
35
import subprocess
46
import sys
57
from pathlib import Path
@@ -17,11 +19,12 @@
1719
from cwltool.job import JobBase
1820
from cwltool.main import main
1921
from cwltool.pathmapper import MapperEnt
22+
from cwltool.singularity import SingularityCommandLineJob
2023
from cwltool.stdfsaccess import StdFsAccess
2124
from cwltool.update import INTERNAL_VERSION, ORIGINAL_CWLVERSION
2225
from cwltool.utils import create_tmp_dir
2326

24-
from .util import get_data, get_main_output, needs_docker
27+
from .util import get_data, get_main_output, needs_docker, needs_singularity
2528

2629

2730
def test_docker_commandLineTool_job_tmpdir_prefix(tmp_path: Path) -> None:
@@ -164,6 +167,65 @@ def test_dockerfile_tmpdir_prefix(tmp_path: Path, monkeypatch: pytest.MonkeyPatc
164167
assert (subdir / "Dockerfile").exists()
165168

166169

170+
@needs_singularity
171+
def test_dockerfile_singularity_build(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
172+
"""Test that SingularityCommandLineJob.get_image builds a Dockerfile with Singularity."""
173+
tmppath = Path(os.environ.get("APPTAINER_TMPDIR", tmp_path))
174+
# some HPC not allowed to execute on /tmp so allow user to define temp path with APPTAINER_TMPDIR
175+
# FATAL: Unable to create build: 'noexec' mount option set on /tmp, temporary root filesystem
176+
monkeypatch.setattr(target=subprocess, name="check_call", value=lambda *args, **kwargs: True)
177+
(tmppath / "out").mkdir(exist_ok=True)
178+
tmp_outdir_prefix = tmppath / "out" / "1"
179+
(tmppath / "3").mkdir(exist_ok=True)
180+
tmpdir_prefix = str(tmppath / "3" / "ttmp")
181+
runtime_context = RuntimeContext(
182+
{"tmpdir_prefix": tmpdir_prefix, "user_space_docker_cmd": None}
183+
)
184+
builder = Builder(
185+
{},
186+
[],
187+
[],
188+
{},
189+
schema.Names(),
190+
[],
191+
[],
192+
{},
193+
None,
194+
None,
195+
StdFsAccess,
196+
StdFsAccess(""),
197+
None,
198+
0.1,
199+
True,
200+
False,
201+
False,
202+
"no_listing",
203+
runtime_context.get_outdir(),
204+
runtime_context.get_tmpdir(),
205+
runtime_context.get_stagedir(),
206+
INTERNAL_VERSION,
207+
"singularity",
208+
)
209+
210+
assert SingularityCommandLineJob(
211+
builder, {}, CommandLineTool.make_path_mapper, [], [], ""
212+
).get_image(
213+
{
214+
"class": "DockerRequirement",
215+
"dockerFile": "FROM debian:stable-slim",
216+
},
217+
pull_image=True,
218+
tmp_outdir_prefix=str(tmp_outdir_prefix),
219+
force_pull=True,
220+
)
221+
children = sorted(tmp_outdir_prefix.parent.glob("*"))
222+
subdir = tmppath / children[0]
223+
children = sorted(subdir.glob("*.sif"))
224+
image_path = children[0]
225+
assert image_path.exists()
226+
shutil.rmtree(subdir)
227+
228+
167229
def test_docker_tmpdir_prefix(tmp_path: Path) -> None:
168230
"""Test that DockerCommandLineJob respects temp directory directives."""
169231
(tmp_path / "3").mkdir()

tox.ini

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ passenv =
3939
CI
4040
GITHUB_*
4141
PROOT_NO_SECCOMP
42+
APPTAINER_TMPDIR
43+
SINGULARITY_FAKEROOT
4244

4345
extras =
4446
py3{8,9,10,11,12}-unit: deps

0 commit comments

Comments
 (0)