Skip to content

Commit 26870e3

Browse files
authored
Merge pull request #1113 from drkennetz/singularity23_fix
Singularity23 fix
2 parents fd6e054 + 1923638 commit 26870e3

File tree

9 files changed

+200
-47
lines changed

9 files changed

+200
-47
lines changed

README.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,14 @@ or
138138
139139
cwltool --user-space-docker-cmd=dx-docker https://raw.githubusercontent.com/common-workflow-language/common-workflow-language/master/v1.0/v1.0/test-cwl-out2.cwl https://github.com/common-workflow-language/common-workflow-language/blob/master/v1.0/v1.0/empty.json
140140
141-
``cwltool`` can use `Singularity <http://singularity.lbl.gov/>`_ as a Docker container runtime, an experimental feature.
142-
Singularity will run software containers specified in ``DockerRequirement`` and therefore works with Docker images only,
143-
native Singularity images are not supported.
144-
To use Singularity as the Docker container runtime, provide ``--singularity`` command line option to ``cwltool``.
141+
``cwltool`` can use `Singularity <http://singularity.lbl.gov/>`_ version 2.6.1
142+
or later as a Docker container runtime.
143+
``cwltool`` with Singularity will run software containers specified in
144+
``DockerRequirement`` and therefore works with Docker images only, native
145+
Singularity images are not supported. To use Singularity as the Docker container
146+
runtime, provide ``--singularity`` command line option to ``cwltool``.
147+
With Singularity, ``cwltool`` can pass all CWL v1.0 conformance tests, except
148+
those involving Docker container ENTRYPOINTs.
145149

146150

147151
.. code:: bash

cwltool/argparser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def arg_parser(): # type: () -> argparse.ArgumentParser
211211
dockergroup.add_argument("--singularity", action="store_true",
212212
default=False, help="[experimental] Use "
213213
"Singularity runtime for running containers. "
214-
"Requires Singularity v2.3.2+ and Linux with kernel "
214+
"Requires Singularity v2.6.1+ and Linux with kernel "
215215
"version v3.18+ or with overlayfs support "
216216
"backported.")
217217
dockergroup.add_argument("--no-container", action="store_false",

cwltool/singularity.py

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
pass
3939

4040
_USERNS = None
41+
_SINGULARITY_VERSION = None
4142

4243
def _singularity_supports_userns(): # type: ()->bool
4344
global _USERNS # pylint: disable=global-statement
@@ -53,10 +54,30 @@ def _singularity_supports_userns(): # type: ()->bool
5354
_USERNS = False
5455
return _USERNS
5556

57+
58+
def get_version(): # type: ()->Text
59+
global _SINGULARITY_VERSION # pylint: disable=global-statement
60+
if not _SINGULARITY_VERSION:
61+
_SINGULARITY_VERSION = check_output(["singularity", "--version"], universal_newlines=True)
62+
if _SINGULARITY_VERSION.startswith("singularity version "):
63+
_SINGULARITY_VERSION = _SINGULARITY_VERSION[20:]
64+
return _SINGULARITY_VERSION
65+
66+
def is_version_2_6(): # type: ()->bool
67+
return get_version().startswith("2.6")
68+
69+
def is_version_3_or_newer(): # type: ()->bool
70+
return int(get_version()[0]) >= 3
71+
72+
def is_version_3_1_or_newer(): # type: ()->bool
73+
version = get_version().split('.')
74+
return int(version[0]) >= 4 or (int(version[0]) == 3 and int(version[1]) >= 1)
75+
5676
def _normalize_image_id(string): # type: (Text)->Text
57-
candidate = re.sub(pattern=r'([a-z]*://)', repl=r'', string=string)
58-
return re.sub(pattern=r'[:/]', repl=r'-', string=candidate) + ".img"
77+
return string.replace('/', '_') + '.img'
5978

79+
def _normalize_sif_id(string): # type: (Text)->Text
80+
return string.replace('/', '_') + '.sif'
6081

6182
class SingularityCommandLineJob(ContainerCommandLineJob):
6283

@@ -77,51 +98,97 @@ def get_image(dockerRequirement, # type: Dict[Text, Text]
7798

7899
candidates = []
79100

101+
cache_folder = None
102+
if "CWL_SINGULARITY_CACHE" in os.environ:
103+
cache_folder = os.environ["CWL_SINGULARITY_CACHE"]
104+
elif is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
105+
cache_folder = os.environ["SINGULARITY_PULLFOLDER"]
106+
80107
if "dockerImageId" not in dockerRequirement and "dockerPull" in dockerRequirement:
81108
match = re.search(pattern=r'([a-z]*://)', string=dockerRequirement["dockerPull"])
82-
candidate = _normalize_image_id(dockerRequirement['dockerPull'])
83-
candidates.append(candidate)
84-
dockerRequirement['dockerImageId'] = candidate
109+
img_name = _normalize_image_id(dockerRequirement['dockerPull'])
110+
candidates.append(img_name)
111+
if is_version_3_or_newer():
112+
sif_name = _normalize_sif_id(dockerRequirement['dockerPull'])
113+
candidates.append(sif_name)
114+
dockerRequirement["dockerImageId"] = sif_name
115+
else:
116+
dockerRequirement["dockerImageId"] = img_name
85117
if not match:
86118
dockerRequirement["dockerPull"] = "docker://" + dockerRequirement["dockerPull"]
87119
elif "dockerImageId" in dockerRequirement:
88120
candidates.append(dockerRequirement['dockerImageId'])
89121
candidates.append(_normalize_image_id(dockerRequirement['dockerImageId']))
122+
if is_version_3_or_newer():
123+
candidates.append(_normalize_sif_id(dockerRequirement['dockerPull']))
90124

91-
# check if Singularity image is available in $SINGULARITY_CACHEDIR
92125
targets = [os.getcwd()]
93-
for env in ("SINGULARITY_CACHEDIR", "SINGULARITY_PULLFOLDER"):
94-
if env in os.environ:
95-
targets.append(os.environ[env])
126+
if "CWL_SINGULARITY_CACHE" in os.environ:
127+
targets.append(os.environ["CWL_SINGULARITY_CACHE"])
128+
if is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
129+
targets.append(os.environ["SINGULARITY_PULLFOLDER"])
96130
for target in targets:
97-
for candidate in candidates:
98-
path = os.path.join(target, candidate)
99-
if os.path.isfile(path):
100-
_logger.info(
101-
"Using local copy of Singularity image found in %s",
102-
target)
103-
dockerRequirement["dockerImageId"] = path
104-
found = True
105-
131+
for dirpath, subdirs, files in os.walk(target):
132+
for entry in files:
133+
if entry in candidates:
134+
path = os.path.join(dirpath, entry)
135+
if os.path.isfile(path):
136+
_logger.info(
137+
"Using local copy of Singularity image found in %s",
138+
dirpath)
139+
dockerRequirement["dockerImageId"] = path
140+
found = True
106141
if (force_pull or not found) and pull_image:
107142
cmd = [] # type: List[Text]
108143
if "dockerPull" in dockerRequirement:
109-
cmd = ["singularity", "pull", "--force", "--name",
110-
str(dockerRequirement["dockerImageId"]),
111-
str(dockerRequirement["dockerPull"])]
112-
_logger.info(Text(cmd))
113-
check_call(cmd, stdout=sys.stderr) # nosec
114-
found = True
144+
if cache_folder:
145+
env = os.environ.copy()
146+
if is_version_2_6():
147+
env['SINGULARITY_PULLFOLDER'] = cache_folder
148+
cmd = ["singularity", "pull", "--force", "--name",
149+
dockerRequirement["dockerImageId"],
150+
str(dockerRequirement["dockerPull"])]
151+
else:
152+
cmd = ["singularity", "pull", "--force", "--name",
153+
"{}/{}".format(
154+
cache_folder,
155+
dockerRequirement["dockerImageId"]),
156+
str(dockerRequirement["dockerPull"])]
157+
158+
_logger.info(Text(cmd))
159+
check_call(cmd, env=env, stdout=sys.stderr) # nosec
160+
dockerRequirement["dockerImageId"] = '{}/{}'.format(
161+
cache_folder, dockerRequirement["dockerImageId"])
162+
found = True
163+
else:
164+
cmd = ["singularity", "pull", "--force", "--name",
165+
str(dockerRequirement["dockerImageId"]),
166+
str(dockerRequirement["dockerPull"])]
167+
_logger.info(Text(cmd))
168+
check_call(cmd, stdout=sys.stderr) # nosec
169+
found = True
170+
115171
elif "dockerFile" in dockerRequirement:
116172
raise WorkflowException(SourceLine(
117173
dockerRequirement, 'dockerFile').makeError(
118174
"dockerFile is not currently supported when using the "
119175
"Singularity runtime for Docker containers."))
120176
elif "dockerLoad" in dockerRequirement:
177+
if is_version_3_1_or_newer():
178+
if 'dockerImageId' in dockerRequirement:
179+
name = "{}.sif".format(dockerRequirement["dockerImageId"])
180+
else:
181+
name = "{}.sif".format(dockerRequirement["dockerLoad"])
182+
cmd = ["singularity", "build", name,
183+
"docker-archive://{}".format(dockerRequirement["dockerLoad"])]
184+
_logger.info(Text(cmd))
185+
check_call(cmd, stdout=sys.stderr) # nosec
186+
found = True
187+
dockerRequirement['dockerImageId'] = name
121188
raise WorkflowException(SourceLine(
122189
dockerRequirement, 'dockerLoad').makeError(
123190
"dockerLoad is not currently supported when using the "
124-
"Singularity runtime for Docker containers."))
191+
"Singularity runtime (version less than 3.1) for Docker containers."))
125192
elif "dockerImport" in dockerRequirement:
126193
raise WorkflowException(SourceLine(
127194
dockerRequirement, 'dockerImport').makeError(
@@ -252,8 +319,8 @@ def add_writable_directory_volume(self,
252319

253320

254321
def create_runtime(self,
255-
env, # type: MutableMapping[Text, Text]
256-
runtime_context # type: RuntimeContext
322+
env, # type: MutableMapping[Text, Text]
323+
runtime_context # type: RuntimeContext
257324
): # type: (...) -> Tuple[List, Optional[Text]]
258325
""" Returns the Singularity runtime list of commands and options."""
259326
any_path_okay = self.builder.get_requirement("DockerRequirement")[1] \
@@ -262,10 +329,16 @@ def create_runtime(self,
262329
u"--ipc"]
263330
if _singularity_supports_userns():
264331
runtime.append(u"--userns")
265-
runtime.append(u"--bind")
266-
runtime.append(u"{}:{}:rw".format(
267-
docker_windows_path_adjust(os.path.realpath(self.outdir)),
268-
self.builder.outdir))
332+
if is_version_3_1_or_newer():
333+
runtime.append(u"--home")
334+
runtime.append(u"{}:{}".format(
335+
docker_windows_path_adjust(os.path.realpath(self.outdir)),
336+
self.builder.outdir))
337+
else:
338+
runtime.append(u"--bind")
339+
runtime.append(u"{}:{}:rw".format(
340+
docker_windows_path_adjust(os.path.realpath(self.outdir)),
341+
self.builder.outdir))
269342
runtime.append(u"--bind")
270343
tmpdir = "/tmp" # nosec
271344
runtime.append(u"{}:{}:rw".format(
@@ -283,6 +356,7 @@ def create_runtime(self,
283356
runtime.append(u"--pwd")
284357
runtime.append(u"%s" % (docker_windows_path_adjust(self.builder.outdir)))
285358

359+
286360
if runtime_context.custom_net:
287361
raise UnsupportedRequirement(
288362
"Singularity implementation does not support custom networking")

tests/debian_image_id.cwl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.0
3+
class: CommandLineTool
4+
5+
requirements:
6+
DockerRequirement:
7+
dockerImageId: 'debian.img'
8+
9+
inputs:
10+
message: string
11+
12+
outputs: []
13+
14+
baseCommand: echo

tests/sing_pullfolder_test.cwl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.0
3+
class: CommandLineTool
4+
5+
requirements:
6+
DockerRequirement:
7+
dockerPull: debian
8+
9+
inputs:
10+
message: string
11+
12+
outputs: []
13+
14+
baseCommand: echo

tests/test_pack.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def test_packed_workflow_execution(wf_path, job_path, namespaced, tmpdir):
154154
packed_output = StringIO()
155155

156156
normal_params = ['--outdir', str(tmpdir), get_data(wf_path), get_data(job_path)]
157-
packed_params = ['--outdir', str(tmpdir), '--debug', get_data(wf_packed_path), get_data(job_path)]
157+
packed_params = ['--outdir', str(tmpdir), '--debug', wf_packed_path, get_data(job_path)]
158158

159159
assert main(normal_params, stdout=normal_output) == 0
160160
assert main(packed_params, stdout=packed_output) == 0

tests/test_singularity.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
import os
23
import pytest
34

45
import distutils.spawn
@@ -8,11 +9,28 @@
89
from cwltool.main import main
910

1011
from .util import (get_data, get_main_output, needs_singularity,
11-
working_directory)
12+
needs_singularity_2_6, working_directory)
1213

1314
sys.argv = ['']
1415

1516

17+
@needs_singularity_2_6
18+
def test_singularity_pullfolder(tmp_path):
19+
workdir = tmp_path / "working_dir_new"
20+
workdir.mkdir()
21+
os.chdir(str(workdir))
22+
pullfolder = tmp_path / "pullfolder"
23+
pullfolder.mkdir()
24+
env = os.environ.copy()
25+
env["SINGULARITY_PULLFOLDER"] = str(pullfolder)
26+
result_code, stdout, stderr = get_main_output(
27+
['--singularity', get_data("tests/sing_pullfolder_test.cwl"), "--message", "hello"], env=env)
28+
print(stdout)
29+
print(stderr)
30+
assert result_code == 0
31+
image = pullfolder/"debian.img"
32+
assert image.exists()
33+
1634
@needs_singularity
1735
def test_singularity_workflow(tmpdir):
1836
with working_directory(str(tmpdir)):
@@ -38,3 +56,23 @@ def test_singularity_incorrect_image_pull():
3856
['--singularity', '--default-container', 'non-existant-weird-image',
3957
get_data("tests/wf/hello-workflow.cwl"), "--usermessage", "hello"])
4058
assert result_code != 0
59+
60+
@needs_singularity
61+
def test_singularity_local(tmp_path):
62+
workdir = tmp_path / "working_dir"
63+
workdir.mkdir()
64+
os.chdir(str(workdir))
65+
result_code, stdout, stderr = get_main_output(
66+
['--singularity', get_data("tests/sing_pullfolder_test.cwl"), "--message", "hello"])
67+
assert result_code == 0
68+
69+
@needs_singularity_2_6
70+
def test_singularity_docker_image_id_in_tool(tmp_path):
71+
workdir = tmp_path / "working_dir"
72+
workdir.mkdir()
73+
os.chdir(str(workdir))
74+
result_code, stdout, stderr = get_main_output(
75+
['--singularity', get_data("tests/sing_pullfolder_test.cwl"), "--message", "hello"])
76+
result_code1, stdout, stderr = get_main_output(
77+
['--singularity', get_data("tests/debian_image_id.cwl"), "--message", "hello"])
78+
assert result_code1 == 0

tests/test_udocker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def test_udocker_usage_should_not_write_cid_file(self, tmpdir):
5151

5252
tmpdir.remove(ignore_errors=True)
5353

54-
assert "completed success" in stderr
54+
assert "completed success" in stderr, stderr
5555
assert cidfiles_count == 0
5656

5757
@pytest.mark.skipif(TRAVIS, reason='Not reliable on single threaded test on travis.')
@@ -63,5 +63,5 @@ def test_udocker_should_display_memory_usage(self, tmpdir):
6363
cwd.chdir()
6464
tmpdir.remove(ignore_errors=True)
6565

66-
assert "completed success" in stderr
67-
assert "Max memory" in stderr
66+
assert "completed success" in stderr, stderr
67+
assert "Max memory" in stderr, stderr

tests/util.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from cwltool.factory import Factory
1919
from cwltool.resolver import Path
2020
from cwltool.utils import onWindows, subprocess, windows_default_container_id
21-
21+
from cwltool.singularity import is_version_2_6, is_version_3_or_newer
2222

2323

2424
def get_windows_safe_factory(runtime_context=None, # type: RuntimeContext
@@ -55,19 +55,28 @@ def get_data(filename):
5555
reason="Requires the docker executable on the "
5656
"system path.")
5757

58-
needs_singularity = pytest.mark.skipif(not bool(distutils.spawn.find_executable('singularity')),
59-
reason="Requires the singularity executable on the "
60-
"system path.")
58+
needs_singularity = pytest.mark.skipif(
59+
not bool(distutils.spawn.find_executable('singularity')),
60+
reason="Requires the singularity executable on the system path.")
61+
62+
needs_singularity_2_6 = pytest.mark.skipif(
63+
not (distutils.spawn.find_executable('singularity') and is_version_2_6()),
64+
reason="Requires that version 2.6.x of singularity executable version is on the system path.")
65+
66+
needs_singularity_3_or_newer = pytest.mark.skipif(
67+
not (distutils.spawn.find_executable('singularity') and is_version_3_or_newer),
68+
reason="Requires that version 2.6.x of singularity executable version is on the system path.")
69+
6170

6271
windows_needs_docker = pytest.mark.skipif(
6372
onWindows() and not bool(distutils.spawn.find_executable('docker')),
6473
reason="Running this test on MS Windows requires the docker executable "
6574
"on the system path.")
6675

67-
def get_main_output(args):
76+
def get_main_output(args, env=None):
6877
process = subprocess.Popen(
6978
[sys.executable, "-m", "cwltool"] + args,
70-
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
79+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
7180

7281
stdout, stderr = process.communicate()
7382
return process.returncode, stdout.decode(), stderr.decode()

0 commit comments

Comments
 (0)