22
33import copy
44import hashlib
5+ import json
56import logging
67import os
78import os .path
1011import sys
1112import threading
1213from collections .abc import Callable , MutableMapping
13- from subprocess import check_call , check_output # nosec
14+ from subprocess import check_call , check_output , run # nosec
1415from typing import cast
1516
17+ from mypy_extensions import mypyc_attr
1618from packaging .version import Version
1719from schema_salad .sourceline import SourceLine
1820from schema_salad .utils import json_dumps
@@ -164,6 +166,30 @@ def _normalize_sif_id(string: str) -> str:
164166 return string .replace ("/" , "_" ) + ".sif"
165167
166168
169+ @mypyc_attr (allow_interpreted_subclasses = True )
170+ def _inspect_singularity_sandbox_image (path : str ) -> bool :
171+ """Inspect singularity sandbox image to be sure it is not an empty directory."""
172+ cmd = [
173+ "singularity" ,
174+ "inspect" ,
175+ "--json" ,
176+ path ,
177+ ]
178+ try :
179+ result = run (cmd , capture_output = True , text = True ) # nosec
180+ except Exception :
181+ return False
182+
183+ if result .returncode == 0 :
184+ try :
185+ output = json .loads (result .stdout )
186+ except json .JSONDecodeError :
187+ return False
188+ if output .get ("data" , {}).get ("attributes" , {}):
189+ return True
190+ return False
191+
192+
167193class SingularityCommandLineJob (ContainerCommandLineJob ):
168194 def __init__ (
169195 self ,
@@ -183,6 +209,7 @@ def get_image(
183209 pull_image : bool ,
184210 tmp_outdir_prefix : str ,
185211 force_pull : bool = False ,
212+ sandbox_base_path : str | None = None ,
186213 ) -> bool :
187214 """
188215 Acquire the software container image in the specified dockerRequirement.
@@ -201,17 +228,34 @@ def get_image(
201228
202229 with _IMAGES_LOCK :
203230 if "dockerImageId" in dockerRequirement :
204- if (d_image_id := dockerRequirement ["dockerImageId" ]) in _IMAGES :
231+ d_image_id = dockerRequirement ["dockerImageId" ]
232+ if d_image_id in _IMAGES :
205233 if (resolved_image_id := _IMAGES [d_image_id ]) != d_image_id :
206234 dockerRequirement ["dockerImage_id" ] = resolved_image_id
207235 return True
236+ if d_image_id .startswith ("/" ):
237+ _logger .info (
238+ SourceLine (dockerRequirement , "dockerImageId" ).makeError (
239+ f"Non-portable: using an absolute file path in a 'dockerImageId': { d_image_id } "
240+ )
241+ )
208242
209243 docker_req = copy .deepcopy (dockerRequirement ) # thread safety
210244 if "CWL_SINGULARITY_CACHE" in os .environ :
211245 cache_folder = os .environ ["CWL_SINGULARITY_CACHE" ]
212246 elif is_version_2_6 () and "SINGULARITY_PULLFOLDER" in os .environ :
213247 cache_folder = os .environ ["SINGULARITY_PULLFOLDER" ]
214248
249+ if os .environ .get ("CWL_SINGULARITY_IMAGES" , None ):
250+ image_base_path = os .environ ["CWL_SINGULARITY_IMAGES" ]
251+ else :
252+ image_base_path = cache_folder if cache_folder else ""
253+
254+ if not sandbox_base_path :
255+ sandbox_base_path = os .path .abspath (image_base_path )
256+ else :
257+ sandbox_base_path = os .path .abspath (sandbox_base_path )
258+
215259 if "dockerFile" in docker_req :
216260 if cache_folder is None : # if environment variables were not set
217261 cache_folder = create_tmp_dir (tmp_outdir_prefix )
@@ -261,21 +305,44 @@ def get_image(
261305 )
262306 found = True
263307 elif "dockerImageId" not in docker_req and "dockerPull" in docker_req :
264- match = re .search (pattern = r"([a-z]*://)" , string = docker_req ["dockerPull" ])
265- img_name = _normalize_image_id (docker_req ["dockerPull" ])
266- candidates .append (img_name )
267- if is_version_3_or_newer ():
268- sif_name = _normalize_sif_id (docker_req ["dockerPull" ])
269- candidates .append (sif_name )
270- docker_req ["dockerImageId" ] = sif_name
308+ # looking for local singularity sandbox image and handle it as a local image
309+ sandbox_image_path = os .path .join (sandbox_base_path , dockerRequirement ["dockerPull" ])
310+ if os .path .isdir (sandbox_image_path ) and _inspect_singularity_sandbox_image (
311+ sandbox_image_path
312+ ):
313+ docker_req ["dockerImageId" ] = sandbox_image_path
314+ _logger .info (
315+ "Using local Singularity sandbox image found in %s" ,
316+ sandbox_image_path ,
317+ )
318+ found = True
271319 else :
272- docker_req ["dockerImageId" ] = img_name
273- if not match :
274- docker_req ["dockerPull" ] = "docker://" + docker_req ["dockerPull" ]
320+ match = re .search (pattern = r"([a-z]*://)" , string = docker_req ["dockerPull" ])
321+ img_name = _normalize_image_id (docker_req ["dockerPull" ])
322+ candidates .append (img_name )
323+ if is_version_3_or_newer ():
324+ sif_name = _normalize_sif_id (docker_req ["dockerPull" ])
325+ candidates .append (sif_name )
326+ docker_req ["dockerImageId" ] = sif_name
327+ else :
328+ docker_req ["dockerImageId" ] = img_name
329+ if not match :
330+ docker_req ["dockerPull" ] = "docker://" + docker_req ["dockerPull" ]
275331 elif "dockerImageId" in docker_req :
276- if os .path .isfile (docker_req ["dockerImageId" ]):
332+ sandbox_image_path = os .path .join (sandbox_base_path , dockerRequirement ["dockerImageId" ])
333+ # handling local singularity sandbox image
334+ if os .path .isdir (sandbox_image_path ) and _inspect_singularity_sandbox_image (
335+ sandbox_image_path
336+ ):
337+ _logger .info (
338+ "Using local Singularity sandbox image found in %s" ,
339+ sandbox_image_path ,
340+ )
341+ docker_req ["dockerImageId" ] = sandbox_image_path
277342 found = True
278343 else :
344+ if os .path .isfile (docker_req ["dockerImageId" ]):
345+ found = True
279346 candidates .append (docker_req ["dockerImageId" ])
280347 candidates .append (_normalize_image_id (docker_req ["dockerImageId" ]))
281348 if is_version_3_or_newer ():
@@ -294,18 +361,19 @@ def get_image(
294361 path = os .path .join (dirpath , entry )
295362 if os .path .isfile (path ):
296363 _logger .info (
297- "Using local copy of Singularity image found in %s" ,
364+ "Using local copy of Singularity image %s found in %s" ,
365+ entry ,
298366 dirpath ,
299367 )
300368 docker_req ["dockerImageId" ] = path
301369 found = True
302370 if (force_pull or not found ) and pull_image :
303371 cmd : list [str ] = []
304372 if "dockerPull" in docker_req :
305- if cache_folder :
373+ if image_base_path :
306374 env = os .environ .copy ()
307375 if is_version_2_6 ():
308- env ["SINGULARITY_PULLFOLDER" ] = cache_folder
376+ env ["SINGULARITY_PULLFOLDER" ] = image_base_path
309377 cmd = [
310378 "singularity" ,
311379 "pull" ,
@@ -320,14 +388,14 @@ def get_image(
320388 "pull" ,
321389 "--force" ,
322390 "--name" ,
323- "{}/{}" .format (cache_folder , docker_req ["dockerImageId" ]),
391+ "{}/{}" .format (image_base_path , docker_req ["dockerImageId" ]),
324392 str (docker_req ["dockerPull" ]),
325393 ]
326394
327395 _logger .info (str (cmd ))
328396 check_call (cmd , env = env , stdout = sys .stderr ) # nosec
329397 docker_req ["dockerImageId" ] = "{}/{}" .format (
330- cache_folder , docker_req ["dockerImageId" ]
398+ image_base_path , docker_req ["dockerImageId" ]
331399 )
332400 found = True
333401 else :
@@ -385,6 +453,7 @@ def get_from_requirements(
385453 pull_image : bool ,
386454 force_pull : bool ,
387455 tmp_outdir_prefix : str ,
456+ image_base_path : str | None = None ,
388457 ) -> str | None :
389458 """
390459 Return the filename of the Singularity image.
@@ -394,8 +463,14 @@ def get_from_requirements(
394463 if not bool (shutil .which ("singularity" )):
395464 raise WorkflowException ("singularity executable is not available" )
396465
397- if not self .get_image (cast (dict [str , str ], r ), pull_image , tmp_outdir_prefix , force_pull ):
398- raise WorkflowException ("Container image {} not found" .format (r ["dockerImageId" ]))
466+ if not self .get_image (
467+ cast (dict [str , str ], r ),
468+ pull_image ,
469+ tmp_outdir_prefix ,
470+ force_pull ,
471+ sandbox_base_path = image_base_path ,
472+ ):
473+ raise WorkflowException (f"Container image not found for { r } " )
399474
400475 return os .path .abspath (cast (str , r ["dockerImageId" ]))
401476
0 commit comments