Skip to content

Commit c1f86f3

Browse files
natthan-pigouxmr-c
authored andcommitted
feat: handle local singularity sandbox image
1 parent fcff727 commit c1f86f3

File tree

4 files changed

+237
-1
lines changed

4 files changed

+237
-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():

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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Tests to find local Singularity image."""
22

33
import shutil
4+
import subprocess
45
from pathlib import Path
56

67
import pytest
78

89
from cwltool.main import main
10+
from cwltool.singularity import _inspect_singularity_image
911

1012
from .util import (
1113
get_data,
@@ -159,3 +161,88 @@ def test_singularity3_docker_image_id_in_tool(tmp_path: Path) -> None:
159161
]
160162
)
161163
assert result_code1 == 0
164+
165+
166+
@needs_singularity
167+
def test_singularity_local_sandbox_image(tmp_path: Path):
168+
workdir = tmp_path / "working_dir"
169+
workdir.mkdir()
170+
with working_directory(workdir):
171+
# build a sandbox image
172+
container_path = workdir / "container_repo"
173+
container_path.mkdir()
174+
cmd = [
175+
"singularity",
176+
"build",
177+
"--sandbox",
178+
str(container_path / "alpine"),
179+
"docker://alpine:latest",
180+
]
181+
182+
build = subprocess.run(cmd, capture_output=True, text=True)
183+
if build.returncode == 0:
184+
result_code, stdout, stderr = get_main_output(
185+
[
186+
"--singularity",
187+
"--disable-pull",
188+
get_data("tests/sing_local_sandbox_test.cwl"),
189+
"--message",
190+
"hello",
191+
]
192+
)
193+
assert result_code == 0
194+
else:
195+
pytest.skip(f"Failed to build the singularity image: {build.stderr}")
196+
197+
198+
@needs_singularity
199+
def test_singularity_inspect_image(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
200+
workdir = tmp_path / "working_dir"
201+
workdir.mkdir()
202+
repo_path = workdir / "container_repo"
203+
image_path = repo_path / "alpine"
204+
205+
# test image exists
206+
repo_path.mkdir()
207+
cmd = [
208+
"singularity",
209+
"build",
210+
"--sandbox",
211+
str(image_path),
212+
"docker://alpine:latest",
213+
]
214+
build = subprocess.run(cmd, capture_output=True, text=True)
215+
if build.returncode == 0:
216+
# Verify the path is a correct container image
217+
res_inspect = _inspect_singularity_image(image_path)
218+
assert res_inspect is True
219+
220+
# test wrong json output
221+
def mock_subprocess_run(*args, **kwargs):
222+
class Result:
223+
returncode = 0
224+
stdout = "not-json"
225+
226+
return Result()
227+
228+
monkeypatch.setattr("cwltool.singularity.run", mock_subprocess_run)
229+
res_inspect = _inspect_singularity_image(image_path)
230+
assert res_inspect is False
231+
else:
232+
pytest.skip(f"singularity sandbox image build didn't worked: {build.stderr}")
233+
234+
235+
def test_singularity_sandbox_image_not_exists():
236+
image_path = "/tmp/not_existing/image"
237+
res_inspect = _inspect_singularity_image(image_path)
238+
assert res_inspect is False
239+
240+
241+
def test_inspect_image_wrong_sb_call(monkeypatch: pytest.MonkeyPatch):
242+
243+
def mock_failed_subprocess(*args, **kwargs):
244+
raise subprocess.CalledProcessError(returncode=1, cmd=args[0])
245+
246+
monkeypatch.setattr("cwltool.singularity.run", mock_failed_subprocess)
247+
res_inspect = _inspect_singularity_image("/tmp/container_repo/alpine")
248+
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)