Skip to content

Commit 6d0f89e

Browse files
adrabentmr-c
andauthored
Avoid pulling existing Singularity image #177 (#178)
Singularity: avoid hitting Docker pull rate limit When using Singularity and in case nodejs is not installed on your system this image is pulled from Docker every time. In case of offline processing and for often pull requests to Docker this should be avoided. port back expr tests from cwltool add test for CWL_SINGULARITY_CACHE Co-authored-by: Michael R. Crusoe <[email protected]>
1 parent 1dd8bfc commit 6d0f89e

File tree

5 files changed

+230
-8
lines changed

5 files changed

+230
-8
lines changed

cwl_utils/sandboxjs.py

+20-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""Safe execution of CWL Expressions in a NodeJS sandbox."""
33
import collections
44
import errno
5+
import glob
56
import json
67
import os
78
import re
@@ -312,28 +313,40 @@ def new_js_proc(
312313
nodeimg = f"docker://{nodeimg}"
313314

314315
if not self.have_node_slim:
316+
singularity_cache: Optional[str] = None
315317
if container_engine in ("docker", "podman"):
316318
dockerimgs = subprocess.check_output( # nosec
317319
[container_engine, "images", "-q", nodeimg],
318320
universal_newlines=True,
319321
)
320-
elif container_engine != "singularity":
322+
elif container_engine == "singularity":
323+
singularity_cache = os.environ.get("CWL_SINGULARITY_CACHE")
324+
if singularity_cache:
325+
singularityimgs = glob.glob(
326+
singularity_cache + "/node_slim.sif"
327+
)
328+
else:
329+
singularityimgs = glob.glob(os.getcwd() + "/node_slim.sif")
330+
else:
321331
raise Exception(
322332
f"Unknown container_engine: {container_engine}."
323333
)
324334
# if output is an empty string
325-
if (
326-
container_engine == "singularity"
327-
or len(dockerimgs.split("\n")) <= 1
328-
or force_docker_pull
329-
):
335+
need_singularity = (
336+
container_engine == "singularity" and not singularityimgs
337+
)
338+
need_docker = container_engine != "singularity" and (
339+
len(dockerimgs.split("\n")) <= 1
340+
)
341+
if need_singularity or need_docker or force_docker_pull:
330342
# pull node:slim docker container
331343
nodejs_pull_commands = [container_engine, "pull"]
332344
if force_docker_pull:
333345
nodejs_pull_commands.append("--force")
334346
nodejs_pull_commands.append(nodeimg)
347+
cwd = singularity_cache if singularity_cache else os.getcwd()
335348
nodejsimg = subprocess.check_output( # nosec
336-
nodejs_pull_commands, universal_newlines=True
349+
nodejs_pull_commands, universal_newlines=True, cwd=cwd
337350
)
338351
_logger.debug(
339352
"Pulled Docker image %s %s using %s",

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
)
3838
.read()
3939
.splitlines(),
40-
tests_require=["pytest<8"],
40+
tests_require=["pytest<8", "pytest-mock"],
4141
test_suite="tests",
4242
extras_require={"pretty": ["cwlformat"]},
4343
entry_points={

test-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ pytest < 8
22
pytest-cov
33
pytest-xdist
44
cwlformat
5+
pytest-mock >= 1.10.0

tests/test_js_sandbox.py

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Test sandboxjs.py and related code."""
2+
import logging
3+
import os
4+
import shutil
5+
import threading
6+
from pathlib import Path
7+
from typing import Any, List
8+
9+
import pytest
10+
11+
from cwl_utils import expression, sandboxjs
12+
13+
from .util import get_data, needs_podman, needs_singularity
14+
15+
node_versions = [
16+
("v0.8.26\n", False),
17+
("v0.10.25\n", False),
18+
("v0.10.26\n", True),
19+
("v4.4.2\n", True),
20+
("v7.7.3\n", True),
21+
]
22+
23+
24+
@pytest.mark.parametrize("version,supported", node_versions)
25+
def test_node_version(version: str, supported: bool, mocker: Any) -> None:
26+
mocked_subprocess = mocker.patch("cwl_utils.sandboxjs.subprocess")
27+
mocked_subprocess.check_output = mocker.Mock(return_value=version)
28+
29+
assert sandboxjs.check_js_threshold_version("node") == supported
30+
31+
32+
def test_value_from_two_concatenated_expressions() -> None:
33+
js_engine = sandboxjs.get_js_engine()
34+
js_engine.have_node_slim = False # type: ignore[attr-defined]
35+
js_engine.localdata = threading.local() # type: ignore[attr-defined]
36+
assert (
37+
expression.do_eval(
38+
'$("a ")$("string")',
39+
{},
40+
[{"class": "InlineJavascriptRequirement"}],
41+
None,
42+
None,
43+
{},
44+
cwlVersion="v1.0",
45+
)
46+
== "a string"
47+
)
48+
49+
50+
def hide_nodejs(temp_dir: Path) -> str:
51+
"""Generate a new PATH that hides node{js,}."""
52+
paths: List[str] = os.environ.get("PATH", "").split(":")
53+
names: List[str] = []
54+
if "/bin" in paths:
55+
bin_path = Path("/bin")
56+
if (
57+
bin_path.is_symlink()
58+
and os.readlink(bin_path) == "usr/bin"
59+
and "/usr/bin" in paths
60+
):
61+
paths.remove("/bin")
62+
for name in ("nodejs", "node"):
63+
path = shutil.which(name, path=":".join(paths))
64+
if path:
65+
names.append(path)
66+
for name in names:
67+
dirname = os.path.dirname(name)
68+
if dirname in paths:
69+
paths.remove(dirname)
70+
new_dir = temp_dir / os.path.basename(dirname)
71+
new_dir.mkdir()
72+
for entry in os.listdir(dirname):
73+
if entry not in ("nodejs", "node"):
74+
os.symlink(os.path.join(dirname, entry), new_dir / entry)
75+
paths.append(str(new_dir))
76+
return ":".join(paths)
77+
78+
79+
@needs_podman
80+
def test_value_from_two_concatenated_expressions_podman(
81+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
82+
) -> None:
83+
"""Javascript test using podman."""
84+
new_paths = hide_nodejs(tmp_path)
85+
with monkeypatch.context() as m:
86+
m.setenv("PATH", new_paths)
87+
js_engine = sandboxjs.get_js_engine()
88+
js_engine.have_node_slim = False # type: ignore[attr-defined]
89+
js_engine.localdata = threading.local() # type: ignore[attr-defined]
90+
assert (
91+
expression.do_eval(
92+
'$("a ")$("string")',
93+
{},
94+
[{"class": "InlineJavascriptRequirement"}],
95+
None,
96+
None,
97+
{},
98+
cwlVersion="v1.0",
99+
container_engine="podman",
100+
)
101+
== "a string"
102+
)
103+
104+
105+
@needs_singularity
106+
def test_value_from_two_concatenated_expressions_singularity(
107+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
108+
) -> None:
109+
"""Javascript test using Singularity."""
110+
new_paths = hide_nodejs(tmp_path)
111+
with monkeypatch.context() as m:
112+
m.setenv("PATH", new_paths)
113+
js_engine = sandboxjs.get_js_engine()
114+
js_engine.have_node_slim = False # type: ignore[attr-defined]
115+
js_engine.localdata = threading.local() # type: ignore[attr-defined]
116+
assert (
117+
expression.do_eval(
118+
'$("a ")$("string")',
119+
{},
120+
[{"class": "InlineJavascriptRequirement"}],
121+
None,
122+
None,
123+
{},
124+
cwlVersion="v1.0",
125+
container_engine="singularity",
126+
)
127+
== "a string"
128+
)
129+
130+
131+
@needs_singularity
132+
def test_singularity_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
133+
"""Affirm that CWL_SINGULARIT_CACHE is respected."""
134+
bin_path = tmp_path / "no_nodejs"
135+
bin_path.mkdir()
136+
new_paths = hide_nodejs(bin_path)
137+
cache_path = tmp_path / "singularity_cache"
138+
cache_path.mkdir()
139+
with monkeypatch.context() as m:
140+
m.setenv("PATH", new_paths)
141+
m.setenv("CWL_SINGULARITY_CACHE", str(cache_path))
142+
js_engine = sandboxjs.get_js_engine()
143+
js_engine.localdata = threading.local() # type: ignore[attr-defined]
144+
js_engine.have_node_slim = False # type: ignore[attr-defined]
145+
assert (
146+
expression.do_eval(
147+
"$(42*23)",
148+
{},
149+
[{"class": "InlineJavascriptRequirement"}],
150+
None,
151+
None,
152+
{},
153+
cwlVersion="v1.0",
154+
container_engine="singularity",
155+
)
156+
== 42 * 23
157+
)
158+
assert (cache_path / "node_slim.sif").exists()
159+
160+
161+
def test_caches_js_processes(mocker: Any) -> None:
162+
sandboxjs.exec_js_process("7", context="{}")
163+
164+
mocked_new_js_proc = mocker.patch("cwl_utils.sandboxjs.new_js_proc")
165+
sandboxjs.exec_js_process("7", context="{}")
166+
167+
mocked_new_js_proc.assert_not_called()

tests/util.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import contextlib
2+
import io
3+
import json
4+
import os
5+
import shutil
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
from typing import Dict, Generator, List, Mapping, Optional, Tuple, Union
10+
11+
import pytest
12+
from pkg_resources import Requirement, ResolutionError, resource_filename
13+
14+
15+
def get_data(filename: str) -> str:
16+
# normalizing path depending on OS or else it will cause problem when joining path
17+
filename = os.path.normpath(filename)
18+
filepath = None
19+
try:
20+
filepath = resource_filename(Requirement.parse("schema-salad"), filename)
21+
except ResolutionError:
22+
pass
23+
if not filepath or not os.path.isfile(filepath):
24+
filepath = os.path.join(os.path.dirname(__file__), os.pardir, filename)
25+
return str(Path(filepath).resolve())
26+
27+
28+
needs_docker = pytest.mark.skipif(
29+
not bool(shutil.which("docker")),
30+
reason="Requires the docker executable on the system path.",
31+
)
32+
33+
needs_singularity = pytest.mark.skipif(
34+
not bool(shutil.which("singularity")),
35+
reason="Requires the singularity executable on the system path.",
36+
)
37+
38+
needs_podman = pytest.mark.skipif(
39+
not bool(shutil.which("podman")),
40+
reason="Requires the podman executable on the system path.",
41+
)

0 commit comments

Comments
 (0)