Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add Singularity support for "dockerFile" #1938

Merged
4 changes: 2 additions & 2 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
key: mypy-${{ env.py-semver }}

- name: Test with tox
run: tox
run: APPTAINER_TMPDIR=${RUNNER_TEMP} tox

- name: Upload coverage to Codecov
if: ${{ matrix.step == 'unit' }}
Expand Down Expand Up @@ -156,7 +156,7 @@ jobs:
chmod a-w .

- name: run tests
run: make test
run: APPTAINER_TMPDIR=${RUNNER_TEMP} make test


conformance_tests:
Expand Down
43 changes: 34 additions & 9 deletions cwltool/singularity.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from typing import Callable, Dict, List, MutableMapping, Optional, Tuple, cast

from schema_salad.sourceline import SourceLine
from spython.main import Client
from spython.main.parse.parsers.docker import DockerParser
from spython.main.parse.writers.singularity import SingularityWriter

from .builder import Builder
from .context import RuntimeContext
Expand Down Expand Up @@ -140,6 +143,7 @@
def get_image(
dockerRequirement: Dict[str, str],
pull_image: bool,
tmp_outdir_prefix: str,
force_pull: bool = False,
) -> bool:
"""
Expand All @@ -162,7 +166,35 @@
elif is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
cache_folder = os.environ["SINGULARITY_PULLFOLDER"]

if "dockerImageId" not in dockerRequirement and "dockerPull" in dockerRequirement:
if "dockerFile" in dockerRequirement:
if cache_folder is None: # if environment variables were not set
cache_folder = create_tmp_dir(tmp_outdir_prefix)

Check warning on line 171 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L171

Added line #L171 was not covered by tests

absolute_path = os.path.abspath(cache_folder)
dockerfile_path = os.path.join(absolute_path, "Dockerfile")
singularityfile_path = dockerfile_path + ".def"

Check warning on line 175 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L173-L175

Added lines #L173 - L175 were not covered by tests
# if you do not set APPTAINER_TMPDIR will crash
# WARNING: 'nodev' mount option set on /tmp, it could be a
# source of failure during build process
# FATAL: Unable to create build: 'noexec' mount option set on
# /tmp, temporary root filesystem won't be usable at this location
with open(dockerfile_path, "w") as dfile:
dfile.write(dockerRequirement["dockerFile"])

Check warning on line 182 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L182

Added line #L182 was not covered by tests

singularityfile = SingularityWriter(DockerParser(dockerfile_path).parse()).convert()

Check warning on line 184 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L184

Added line #L184 was not covered by tests
with open(singularityfile_path, "w") as file:
file.write(singularityfile)

Check warning on line 186 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L186

Added line #L186 was not covered by tests

os.environ["APPTAINER_TMPDIR"] = absolute_path
singularity_options = ["--fakeroot"] if not shutil.which("proot") else []
Client.build(

Check warning on line 190 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L188-L190

Added lines #L188 - L190 were not covered by tests
recipe=singularityfile_path,
build_folder=absolute_path,
sudo=False,
options=singularity_options,
)
found = True

Check warning on line 196 in cwltool/singularity.py

View check run for this annotation

Codecov / codecov/patch

cwltool/singularity.py#L196

Added line #L196 was not covered by tests
elif "dockerImageId" not in dockerRequirement and "dockerPull" in dockerRequirement:
match = re.search(pattern=r"([a-z]*://)", string=dockerRequirement["dockerPull"])
img_name = _normalize_image_id(dockerRequirement["dockerPull"])
candidates.append(img_name)
Expand Down Expand Up @@ -243,13 +275,6 @@
check_call(cmd, stdout=sys.stderr) # nosec
found = True

elif "dockerFile" in dockerRequirement:
raise SourceLine(
dockerRequirement, "dockerFile", WorkflowException, debug
).makeError(
"dockerFile is not currently supported when using the "
"Singularity runtime for Docker containers."
)
elif "dockerLoad" in dockerRequirement:
if is_version_3_1_or_newer():
if "dockerImageId" in dockerRequirement:
Expand Down Expand Up @@ -298,7 +323,7 @@
if not bool(shutil.which("singularity")):
raise WorkflowException("singularity executable is not available")

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

return os.path.abspath(cast(str, r["dockerImageId"]))
Expand Down
9 changes: 9 additions & 0 deletions mypy-stubs/spython/main/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Iterator, Optional

from .base import Client as _BaseClient
from .build import build as base_build

class _Client(_BaseClient):
build = base_build

Client = _Client()
3 changes: 3 additions & 0 deletions mypy-stubs/spython/main/base/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Client:
def __init__(self) -> None: ...
def version(self) -> str: ...
23 changes: 23 additions & 0 deletions mypy-stubs/spython/main/build.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Iterator, Optional

from .base import Client

def build(
self: Client,
recipe: Optional[str] = ...,
image: Optional[str] = ...,
isolated: Optional[bool] = ...,
sandbox: Optional[bool] = ...,
writable: Optional[bool] = ...,
build_folder: Optional[str] = ...,
robot_name: Optional[bool] = ...,
ext: Optional[str] = ...,
sudo: Optional[bool] = ...,
stream: Optional[bool] = ...,
force: Optional[bool] = ...,
options: Optional[list[str]] | None = ...,
quiet: Optional[bool] = ...,
return_result: Optional[bool] = ...,
sudo_options: Optional[str | list[str]] = ...,
singularity_options: Optional[list[str]] = ...,
) -> tuple[str, Iterator[str]]: ...
14 changes: 14 additions & 0 deletions mypy-stubs/spython/main/parse/parsers/base.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import abc

from ..recipe import Recipe

class ParserBase(metaclass=abc.ABCMeta):
filename: str
lines: list[str]
args: dict[str, str]
active_layer: str
active_layer_num: int
recipe: dict[str, Recipe]
def __init__(self, filename: str, load: bool = ...) -> None: ...
@abc.abstractmethod
def parse(self) -> dict[str, Recipe]: ...
7 changes: 7 additions & 0 deletions mypy-stubs/spython/main/parse/parsers/docker.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from ..recipe import Recipe
from .base import ParserBase as ParserBase

class DockerParser(ParserBase):
name: str
def __init__(self, filename: str = ..., load: bool = ...) -> None: ...
def parse(self) -> dict[str, Recipe]: ...
19 changes: 19 additions & 0 deletions mypy-stubs/spython/main/parse/recipe.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Optional

class Recipe:
cmd: Optional[str]
comments: list[str]
entrypoint: Optional[str]
environ: list[str]
files: list[str]
layer_files: dict[str, str]
install: list[str]
labels: list[str]
ports: list[str]
test: Optional[str]
volumes: list[str]
workdir: Optional[str]
layer: int
fromHeader: Optional[str]
source: Optional[Recipe]
def __init__(self, recipe: Optional[Recipe] = ..., layer: int = ...) -> None: ...
6 changes: 6 additions & 0 deletions mypy-stubs/spython/main/parse/writers/base.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from ..recipe import Recipe

class WriterBase:
recipe: dict[str, Recipe]
def __init__(self, recipe: dict[str, Recipe] | None = ...) -> None: ...
def write(self, output_file: str | None = ..., force: bool = ...) -> None: ...
10 changes: 10 additions & 0 deletions mypy-stubs/spython/main/parse/writers/singularity.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from typing import Optional

from ..recipe import Recipe
from .base import WriterBase as WriterBase

class SingularityWriter(WriterBase):
name: str
def __init__(self, recipe: Optional[dict[str, Recipe]] = ...) -> None: ...
def validate(self) -> None: ...
def convert(self, runscript: str = ..., force: bool = ...) -> str: ...
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ pydot>=1.4.1
argcomplete>=1.12.0
pyparsing!=3.0.2 # breaks --print-dot (pydot) https://github.com/pyparsing/pyparsing/issues/319
cwl-utils>=0.32
spython>=0.3.0
64 changes: 63 additions & 1 deletion tests/test_tmpdir.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Test that all temporary directories respect the --tmpdir-prefix and --tmp-outdir-prefix options."""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
Expand All @@ -17,11 +19,12 @@
from cwltool.job import JobBase
from cwltool.main import main
from cwltool.pathmapper import MapperEnt
from cwltool.singularity import SingularityCommandLineJob
from cwltool.stdfsaccess import StdFsAccess
from cwltool.update import INTERNAL_VERSION, ORIGINAL_CWLVERSION
from cwltool.utils import create_tmp_dir

from .util import get_data, get_main_output, needs_docker
from .util import get_data, get_main_output, needs_docker, needs_singularity


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


@needs_singularity
def test_dockerfile_singularity_build(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Test that SingularityCommandLineJob.get_image builds a Dockerfile with Singularity."""
tmppath = Path(os.environ.get("APPTAINER_TMPDIR", tmp_path))
# some HPC not allowed to execute on /tmp so allow user to define temp path with APPTAINER_TMPDIR
# FATAL: Unable to create build: 'noexec' mount option set on /tmp, temporary root filesystem
monkeypatch.setattr(target=subprocess, name="check_call", value=lambda *args, **kwargs: True)
(tmppath / "out").mkdir(exist_ok=True)
tmp_outdir_prefix = tmppath / "out" / "1"
(tmppath / "3").mkdir(exist_ok=True)
tmpdir_prefix = str(tmppath / "3" / "ttmp")
runtime_context = RuntimeContext(
{"tmpdir_prefix": tmpdir_prefix, "user_space_docker_cmd": None}
)
builder = Builder(
{},
[],
[],
{},
schema.Names(),
[],
[],
{},
None,
None,
StdFsAccess,
StdFsAccess(""),
None,
0.1,
True,
False,
False,
"no_listing",
runtime_context.get_outdir(),
runtime_context.get_tmpdir(),
runtime_context.get_stagedir(),
INTERNAL_VERSION,
"singularity",
)

assert SingularityCommandLineJob(
builder, {}, CommandLineTool.make_path_mapper, [], [], ""
).get_image(
{
"class": "DockerRequirement",
"dockerFile": "FROM debian:stable-slim",
},
pull_image=True,
tmp_outdir_prefix=str(tmp_outdir_prefix),
force_pull=True,
)
children = sorted(tmp_outdir_prefix.parent.glob("*"))
subdir = tmppath / children[0]
children = sorted(subdir.glob("*.sif"))
image_path = children[0]
assert image_path.exists()
shutil.rmtree(subdir)


def test_docker_tmpdir_prefix(tmp_path: Path) -> None:
"""Test that DockerCommandLineJob respects temp directory directives."""
(tmp_path / "3").mkdir()
Expand Down
2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ passenv =
CI
GITHUB_*
PROOT_NO_SECCOMP
APPTAINER_TMPDIR
SINGULARITY_FAKEROOT

extras =
py3{8,9,10,11,12}-unit: deps
Expand Down