Skip to content

Commit 8aa63a8

Browse files
natthan-pigouxnatthan-pigoux
authored andcommitted
feat: handle local singularity sandbox image
1 parent 7be5a2f commit 8aa63a8

File tree

5 files changed

+280
-1
lines changed

5 files changed

+280
-1
lines changed

cwltool/singularity.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Support for executing Docker format containers using Singularity {2,3}.x or Apptainer 1.x."""
22

3+
import json
34
import logging
45
import os
56
import os.path
67
import re
78
import shutil
89
import sys
910
from collections.abc import Callable, MutableMapping
10-
from subprocess import check_call, check_output # nosec
11+
from subprocess import check_call, check_output, run # nosec
1112
from typing import cast
1213

1314
from schema_salad.sourceline import SourceLine
@@ -145,6 +146,29 @@ def _normalize_sif_id(string: str) -> str:
145146
return string.replace("/", "_") + ".sif"
146147

147148

149+
def _inspect_singularity_image(path: str) -> bool:
150+
"""Inspect singularity image to be sure it is not an empty directory."""
151+
cmd = [
152+
"singularity",
153+
"inspect",
154+
"--json",
155+
path,
156+
]
157+
try:
158+
result = run(cmd, capture_output=True, text=True) # nosec
159+
except Exception:
160+
return False
161+
162+
if result.returncode == 0:
163+
try:
164+
output = json.loads(result.stdout)
165+
except json.JSONDecodeError:
166+
return False
167+
if output.get("data", {}).get("attributes", {}):
168+
return True
169+
return False
170+
171+
148172
class SingularityCommandLineJob(ContainerCommandLineJob):
149173
def __init__(
150174
self,
@@ -229,6 +253,16 @@ def get_image(
229253
)
230254
found = True
231255
elif "dockerImageId" not in dockerRequirement and "dockerPull" in dockerRequirement:
256+
# looking for local singularity sandbox image and handle it as a local image
257+
if os.path.isdir(dockerRequirement["dockerPull"]) and _inspect_singularity_image(
258+
dockerRequirement["dockerPull"]
259+
):
260+
dockerRequirement["dockerImageId"] = dockerRequirement["dockerPull"]
261+
_logger.info(
262+
"Using local Singularity sandbox image found in %s",
263+
dockerRequirement["dockerImageId"],
264+
)
265+
return True
232266
match = re.search(pattern=r"([a-z]*://)", string=dockerRequirement["dockerPull"])
233267
img_name = _normalize_image_id(dockerRequirement["dockerPull"])
234268
candidates.append(img_name)
@@ -243,6 +277,15 @@ def get_image(
243277
elif "dockerImageId" in dockerRequirement:
244278
if os.path.isfile(dockerRequirement["dockerImageId"]):
245279
found = True
280+
# handling local singularity sandbox image
281+
elif os.path.isdir(dockerRequirement["dockerImageId"]) and _inspect_singularity_image(
282+
dockerRequirement["dockerImageId"]
283+
):
284+
_logger.info(
285+
"Using local Singularity sandbox image found in %s",
286+
dockerRequirement["dockerImageId"],
287+
)
288+
return True
246289
candidates.append(dockerRequirement["dockerImageId"])
247290
candidates.append(_normalize_image_id(dockerRequirement["dockerImageId"]))
248291
if is_version_3_or_newer():
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: container_repo/alpine
8+
9+
inputs:
10+
message: string
11+
12+
outputs: []
13+
14+
baseCommand: echo

tests/sing_local_sandbox_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: container_repo/alpine
8+
9+
inputs:
10+
message: string
11+
12+
outputs: []
13+
14+
baseCommand: echo

tests/test_singularity.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Tests to find local Singularity image."""
22

3+
import json
34
import shutil
5+
import subprocess
46
from pathlib import Path
57

68
import pytest
79

810
from cwltool.main import main
11+
from cwltool.singularity import _inspect_singularity_image
912

1013
from .util import (
1114
get_data,
@@ -159,3 +162,116 @@ def test_singularity3_docker_image_id_in_tool(tmp_path: Path) -> None:
159162
]
160163
)
161164
assert result_code1 == 0
165+
166+
167+
@needs_singularity
168+
def test_singularity_local_sandbox_image(tmp_path: Path):
169+
workdir = tmp_path / "working_dir"
170+
workdir.mkdir()
171+
with working_directory(workdir):
172+
# build a sandbox image
173+
container_path = workdir / "container_repo"
174+
container_path.mkdir()
175+
cmd = [
176+
"singularity",
177+
"build",
178+
"--sandbox",
179+
str(container_path / "alpine"),
180+
"docker://alpine:latest",
181+
]
182+
183+
build = subprocess.run(cmd, capture_output=True, text=True)
184+
if build.returncode == 0:
185+
result_code, stdout, stderr = get_main_output(
186+
[
187+
"--singularity",
188+
"--disable-pull",
189+
get_data("tests/sing_local_sandbox_test.cwl"),
190+
"--message",
191+
"hello",
192+
]
193+
)
194+
assert result_code == 0
195+
result_code, stdout, stderr = get_main_output(
196+
[
197+
"--singularity",
198+
"--disable-pull",
199+
get_data("tests/sing_local_sandbox_img_id_test.cwl"),
200+
"--message",
201+
"hello",
202+
]
203+
)
204+
assert result_code == 0
205+
else:
206+
pytest.skip(f"Failed to build the singularity image: {build.stderr}")
207+
208+
209+
@needs_singularity
210+
def test_singularity_inspect_image(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
211+
"""Test inspect a real image works."""
212+
workdir = tmp_path / "working_dir"
213+
workdir.mkdir()
214+
repo_path = workdir / "container_repo"
215+
image_path = repo_path / "alpine"
216+
217+
# test image exists
218+
repo_path.mkdir()
219+
cmd = [
220+
"singularity",
221+
"build",
222+
"--sandbox",
223+
str(image_path),
224+
"docker://alpine:latest",
225+
]
226+
build = subprocess.run(cmd, capture_output=True, text=True)
227+
if build.returncode == 0:
228+
# Verify the path is a correct container image
229+
res_inspect = _inspect_singularity_image(image_path)
230+
assert res_inspect is True
231+
else:
232+
pytest.skip(f"singularity sandbox image build didn't worked: {build.stderr}")
233+
234+
def _make_run_result(returncode: int, stdout: str):
235+
"""Mock subprocess.run returning returncode and stdout."""
236+
class DummyResult:
237+
def __init__(self, rc, out):
238+
self.returncode = rc
239+
self.stdout = out
240+
241+
def _runner(*args, **kwargs):
242+
return DummyResult(returncode, stdout)
243+
244+
return _runner
245+
246+
def test_json_decode_error_branch(monkeypatch):
247+
"""Test json can't decode inspect result."""
248+
monkeypatch.setattr("cwltool.singularity.run", _make_run_result(0, "not-a-json"))
249+
250+
def _raise_json_error(s):
251+
# construct and raise an actual JSONDecodeError
252+
raise json.JSONDecodeError("Expecting value", s, 0)
253+
254+
# Patch json.loads so it raises JSONDecodeError when called
255+
monkeypatch.setattr("json.loads", _raise_json_error)
256+
257+
assert _inspect_singularity_image("/tmp/image") is False
258+
259+
def test_singularity_sandbox_image_not_exists():
260+
image_path = "/tmp/not_existing/image"
261+
res_inspect = _inspect_singularity_image(image_path)
262+
assert res_inspect is False
263+
264+
def test_singularity_sandbox_not_an_image(tmp_path: Path):
265+
image_path = tmp_path / "image"
266+
image_path.mkdir()
267+
res_inspect = _inspect_singularity_image(image_path)
268+
assert res_inspect is False
269+
270+
def test_inspect_image_wrong_sb_call(monkeypatch: pytest.MonkeyPatch):
271+
272+
def mock_failed_subprocess(*args, **kwargs):
273+
raise subprocess.CalledProcessError(returncode=1, cmd=args[0])
274+
275+
monkeypatch.setattr("cwltool.singularity.run", mock_failed_subprocess)
276+
res_inspect = _inspect_singularity_image("/tmp/container_repo/alpine")
277+
assert res_inspect is False

tests/test_tmpdir.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,98 @@ def test_dockerfile_singularity_build(monkeypatch: pytest.MonkeyPatch, tmp_path:
285285
shutil.rmtree(subdir)
286286

287287

288+
@needs_singularity
289+
def test_singularity_get_image_from_sandbox(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
290+
"""Test that SingularityCommandLineJob.get_image correctly handle sandbox image."""
291+
292+
(tmp_path / "out").mkdir(exist_ok=True)
293+
tmp_outdir_prefix = tmp_path / "out"
294+
tmp_outdir_prefix.mkdir(exist_ok=True)
295+
(tmp_path / "tmp").mkdir(exist_ok=True)
296+
tmpdir_prefix = str(tmp_path / "tmp")
297+
runtime_context = RuntimeContext(
298+
{"tmpdir_prefix": tmpdir_prefix, "user_space_docker_cmd": None}
299+
)
300+
builder = Builder(
301+
{},
302+
[],
303+
[],
304+
{},
305+
schema.Names(),
306+
[],
307+
[],
308+
{},
309+
None,
310+
None,
311+
StdFsAccess,
312+
StdFsAccess(""),
313+
None,
314+
0.1,
315+
True,
316+
False,
317+
False,
318+
"no_listing",
319+
runtime_context.get_outdir(),
320+
runtime_context.get_tmpdir(),
321+
runtime_context.get_stagedir(),
322+
INTERNAL_VERSION,
323+
"singularity",
324+
)
325+
326+
workdir = tmp_path / "working_dir"
327+
workdir.mkdir()
328+
repo_path = workdir / "container_repo"
329+
repo_path.mkdir()
330+
image_path = repo_path / "alpine"
331+
image_path.mkdir()
332+
333+
# directory exists but is not an image
334+
monkeypatch.setattr(
335+
"cwltool.singularity._inspect_singularity_image", lambda *args, **kwargs: False
336+
)
337+
req = {"class": "DockerRequirement", "dockerPull": f"{image_path}"}
338+
res = SingularityCommandLineJob(
339+
builder, {}, CommandLineTool.make_path_mapper, [], [], ""
340+
).get_image(
341+
req,
342+
pull_image=False,
343+
tmp_outdir_prefix=str(tmp_outdir_prefix),
344+
force_pull=False,
345+
)
346+
assert req["dockerPull"].startswith("docker://")
347+
assert res is False
348+
349+
# directory exists and is an image:
350+
monkeypatch.setattr(
351+
"cwltool.singularity._inspect_singularity_image", lambda *args, **kwargs: True
352+
)
353+
req = {"class": "DockerRequirement", "dockerPull": f"{image_path}"}
354+
res = SingularityCommandLineJob(
355+
builder, {}, CommandLineTool.make_path_mapper, [], [], ""
356+
).get_image(
357+
req,
358+
pull_image=False,
359+
tmp_outdir_prefix=str(tmp_outdir_prefix),
360+
force_pull=False,
361+
)
362+
assert req["dockerPull"] == str(image_path)
363+
assert req["dockerImageId"] == str(image_path)
364+
assert res
365+
366+
# test that dockerImageId is set and image exists:
367+
req = {"class": "DockerRequirement", "dockerImageId": f"{image_path}"}
368+
res = SingularityCommandLineJob(
369+
builder, {}, CommandLineTool.make_path_mapper, [], [], ""
370+
).get_image(
371+
req,
372+
pull_image=False,
373+
tmp_outdir_prefix=str(tmp_outdir_prefix),
374+
force_pull=False,
375+
)
376+
assert req["dockerImageId"] == str(image_path)
377+
assert res
378+
379+
288380
def test_docker_tmpdir_prefix(tmp_path: Path) -> None:
289381
"""Test that DockerCommandLineJob respects temp directory directives."""
290382
(tmp_path / "3").mkdir()

0 commit comments

Comments
 (0)