Skip to content

Commit cf52d0c

Browse files
committed
Sync up unique cache paths for Singularity images with cwl-utils
1 parent 8949fc2 commit cf52d0c

2 files changed

Lines changed: 98 additions & 15 deletions

File tree

cwltool/singularity.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,65 @@ def is_version_3_10_or_newer() -> bool:
155155
return version >= Version("3.10")
156156

157157

158+
def _encode_container_image(string: str) -> str:
159+
"""
160+
Produce a unique filename-safe string for a container image.
161+
162+
The input may be a Docker container specifier like "ubuntu",
163+
"ubuntu:latest", "quay.io/adamnovak/hap.py:v0.3.15",
164+
"quay.io/adamnovak/hap.py@sha256:f483da1b1c3d58be028a9599db4255cf1eb8570d50f901f10c8499fc36a94418",
165+
etc., or it may be something with a protocol like "docker://ubuntu" or
166+
"https://library.sylabs.io/v1/imagefile/library/default/alpine:latest", or
167+
it may be a file path not ending in ":latest".
168+
169+
(It may not be a file path ending in ":latest" because it is too hard to
170+
work out that the corresponding path without ":latest" should not have
171+
":latest" appended to it.)
172+
173+
If the input does not have a tag or digest, and a protocol was not used,
174+
the "latest" tag will be used.
175+
176+
Distinct strings that refer to the same image (like "library/ubuntu",
177+
"docker.io/library/ubuntu", and "docker://ubuntu:latest") will not
178+
necessarily always produce the same result.
179+
180+
No two distinct inputs that refer to distinct container images will produce
181+
the same result. The result is human-readable.
182+
"""
183+
184+
# See https://stackoverflow.com/a/43091578 for information on allowed
185+
# patterns of Docker image specifiers.
186+
has_tag_or_digest = re.search(pattern=r"(:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}|@[a-z0-9]+([+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+)$", string=string)
187+
188+
has_protocol = re.search(pattern=r"^[a-zA-Z0-9-_]+://", string=string)
189+
190+
if not has_tag_or_digest and not has_protocol:
191+
# We apply :latest to *everything* that doesn't seem to have a tag or a
192+
# protocol
193+
string += ":latest"
194+
195+
# First we need to escape existing underscores, and then we need to use
196+
# underscore-based escapes to represent characters not allowed in
197+
# filenames. There's sadly no one obvious way to do this.
198+
return string.replace("_", "___").replace("/", "_s_")
199+
200+
158201
def _normalize_image_id(string: str) -> str:
159-
if ":" not in string:
160-
string += "_latest"
161-
return string.replace("/", "_") + ".img"
202+
return _encode_container_image(string) + ".img"
162203

163204

164205
def _normalize_sif_id(string: str) -> str:
165-
if ":" not in string:
166-
string += "_latest"
167-
return string.replace("/", "_") + ".sif"
206+
"""
207+
Produce a .sif filename for a container image.
208+
209+
This is guaranteed to be the same as the path at which cwl-docker-extract
210+
from the cwl-utils project will save the .sif within its target directory,
211+
for inputs supported by cwl-docker-extract.
212+
213+
When two inputs refer to different images, the results for those inputs are
214+
guaranteed to be distinct.
215+
"""
216+
return _encode_container_image(string) + ".sif"
168217

169218

170219
@mypyc_attr(allow_interpreted_subclasses=True)

tests/test_singularity.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
_IMAGES,
1616
_IMAGES_LOCK,
1717
_inspect_singularity_sandbox_image,
18+
_normalize_sif_id,
1819
)
1920

2021
from .util import (
@@ -33,6 +34,39 @@ def clear_singularity_image_cache() -> None:
3334
_IMAGES.clear()
3435

3536

37+
def test_normalize_sif_id_resists_collision() -> None:
38+
"""Test that tricky image names can't collide."""
39+
assert _normalize_sif_id("ubuntu:latest") != _normalize_sif_id("ubuntu_latest")
40+
assert _normalize_sif_id("docker://user_name/repo") != _normalize_sif_id("docker://user/name_repo")
41+
assert _normalize_sif_id("http://something.com/something.sif") != _normalize_sif_id("http:_something.com/something.sif")
42+
43+
44+
def test_normalize_sif_id_implies_latest() -> None:
45+
"""
46+
Test that image names that imply latest are equivalent to those that
47+
specify it.
48+
"""
49+
assert _normalize_sif_id("ubuntu") == _normalize_sif_id("ubuntu:latest")
50+
assert _normalize_sif_id("quay.io/adamnovak/hap.py") == _normalize_sif_id("quay.io/adamnovak/hap.py:latest")
51+
52+
53+
def test_normalize_sif_id_does_not_tag_protocols() -> None:
54+
"""
55+
Test that if an image comes from a string with a protocol, no tag is added.
56+
"""
57+
assert _normalize_sif_id("docker://library/ubuntu") != _normalize_sif_id("docker://library/ubuntu:latest")
58+
59+
60+
def test_normalize_sif_id_matches_cwl_utils_tests() -> None:
61+
"""
62+
Make sure that the particular values tested in cwl-utils get the same
63+
answers here as there.
64+
"""
65+
66+
assert _normalize_sif_id("some_name/repo:123") == f"some___name_s_repo:123.sif"
67+
assert _normalize_sif_id("some/name_repo:123") == f"some_s_name___repo:123.sif"
68+
69+
3670
@needs_singularity_2_6
3771
def test_singularity_pullfolder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3872
"""Test singularity respects SINGULARITY_PULLFOLDER."""
@@ -194,7 +228,7 @@ def test_singularity_dockerfile_no_name_no_cache(tmp_path: Path) -> None:
194228
]
195229
)
196230
assert result_code == 0, stderr
197-
assert not (workdir / "bea92b9b6910cbbd2ae602f5bb0f0f27_latest.sif").exists()
231+
assert not (workdir / _normalize_sif_id("bea92b9b6910cbbd2ae602f5bb0f0f27")).exists()
198232

199233

200234
@needs_singularity_3_or_newer
@@ -217,8 +251,8 @@ def test_singularity_dockerfile_no_name_with_cache(
217251
]
218252
)
219253
assert result_code == 0, stderr
220-
assert not (workdir / "bea92b9b6910cbbd2ae602f5bb0f0f27_latest.sif").exists()
221-
assert (cachedir / "bea92b9b6910cbbd2ae602f5bb0f0f27_latest.sif").exists()
254+
assert not (workdir / _normalize_sif_id("bea92b9b6910cbbd2ae602f5bb0f0f27")).exists()
255+
assert (cachedir / _normalize_sif_id("bea92b9b6910cbbd2ae602f5bb0f0f27")).exists()
222256

223257

224258
@needs_singularity_3_or_newer
@@ -236,8 +270,8 @@ def test_singularity_dockerfile_with_name_no_cache(tmp_path: Path) -> None:
236270
)
237271
assert result_code == 0, stderr
238272
print(list(workdir.iterdir()))
239-
assert not (workdir / "bea92b9b6910cbbd2ae602f5bb0f0f27_latest.sif").exists()
240-
assert not (workdir / "customDebian_latest.sif").exists()
273+
assert not (workdir / _normalize_sif_id("bea92b9b6910cbbd2ae602f5bb0f0f27")).exists()
274+
assert not (workdir / _normalize_sif_id("customDebian")).exists()
241275

242276

243277
@needs_singularity_3_or_newer
@@ -262,10 +296,10 @@ def test_singularity_dockerfile_with_name_with_cache(
262296
print(list(workdir.iterdir()))
263297
print(list(cachedir.iterdir()))
264298
assert result_code == 0, stderr
265-
assert not (workdir / "bea92b9b6910cbbd2ae602f5bb0f0f27_latest.sif").exists()
266-
assert not (cachedir / "bea92b9b6910cbbd2ae602f5bb0f0f27_latest.sif").exists()
267-
assert not (workdir / "customDebian_latest.sif").exists()
268-
assert (cachedir / "customDebian_latest.sif").exists()
299+
assert not (workdir / _normalize_sif_id("bea92b9b6910cbbd2ae602f5bb0f0f27")).exists()
300+
assert not (cachedir / _normalize_sif_id("bea92b9b6910cbbd2ae602f5bb0f0f27")).exists()
301+
assert not (workdir / _normalize_sif_id("customDebian")).exists()
302+
assert (cachedir / _normalize_sif_id("customDebian")).exists()
269303

270304

271305
@needs_singularity

0 commit comments

Comments
 (0)