comparison env/lib/python3.9/site-packages/cwltool/singularity.py @ 0:4f3585e2f14b draft default tip

"planemo upload commit 60cee0fc7c0cda8592644e1aad72851dec82c959"
author shellac
date Mon, 22 Mar 2021 18:12:50 +0000
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:4f3585e2f14b
1 """Support for executing Docker containers using the Singularity 2.x engine."""
2
3 import os
4 import os.path
5 import re
6 import shutil
7 import sys
8 from distutils import spawn
9 from subprocess import ( # nosec
10 DEVNULL,
11 PIPE,
12 Popen,
13 TimeoutExpired,
14 check_call,
15 check_output,
16 )
17 from typing import Callable, Dict, List, MutableMapping, Optional, Tuple, cast
18
19 from schema_salad.sourceline import SourceLine
20
21 from .builder import Builder
22 from .context import RuntimeContext
23 from .errors import UnsupportedRequirement, WorkflowException
24 from .job import ContainerCommandLineJob
25 from .loghandler import _logger
26 from .pathmapper import MapperEnt, PathMapper
27 from .utils import (
28 CWLObjectType,
29 create_tmp_dir,
30 docker_windows_path_adjust,
31 ensure_non_writable,
32 ensure_writable,
33 )
34
35 _USERNS = None # type: Optional[bool]
36 _SINGULARITY_VERSION = ""
37
38
39 def _singularity_supports_userns() -> bool:
40 global _USERNS # pylint: disable=global-statement
41 if _USERNS is None:
42 try:
43 hello_image = os.path.join(os.path.dirname(__file__), "hello.simg")
44 result = Popen( # nosec
45 ["singularity", "exec", "--userns", hello_image, "true"],
46 stderr=PIPE,
47 stdout=DEVNULL,
48 universal_newlines=True,
49 ).communicate(timeout=60)[1]
50 _USERNS = (
51 "No valid /bin/sh" in result
52 or "/bin/sh doesn't exist in container" in result
53 or "executable file not found in" in result
54 )
55 except TimeoutExpired:
56 _USERNS = False
57 return _USERNS
58
59
60 def get_version() -> str:
61 global _SINGULARITY_VERSION # pylint: disable=global-statement
62 if not _SINGULARITY_VERSION:
63 _SINGULARITY_VERSION = check_output( # nosec
64 ["singularity", "--version"], universal_newlines=True
65 )
66 if _SINGULARITY_VERSION.startswith("singularity version "):
67 _SINGULARITY_VERSION = _SINGULARITY_VERSION[20:]
68 return _SINGULARITY_VERSION
69
70
71 def is_version_2_6() -> bool:
72 return get_version().startswith("2.6")
73
74
75 def is_version_3_or_newer() -> bool:
76 return int(get_version()[0]) >= 3
77
78
79 def is_version_3_1_or_newer() -> bool:
80 version = get_version().split(".")
81 return int(version[0]) >= 4 or (int(version[0]) == 3 and int(version[1]) >= 1)
82
83
84 def _normalize_image_id(string: str) -> str:
85 return string.replace("/", "_") + ".img"
86
87
88 def _normalize_sif_id(string: str) -> str:
89 return string.replace("/", "_") + ".sif"
90
91
92 class SingularityCommandLineJob(ContainerCommandLineJob):
93 def __init__(
94 self,
95 builder: Builder,
96 joborder: CWLObjectType,
97 make_path_mapper: Callable[..., PathMapper],
98 requirements: List[CWLObjectType],
99 hints: List[CWLObjectType],
100 name: str,
101 ) -> None:
102 """Builder for invoking the Singularty software container engine."""
103 super().__init__(builder, joborder, make_path_mapper, requirements, hints, name)
104
105 @staticmethod
106 def get_image(
107 dockerRequirement: Dict[str, str],
108 pull_image: bool,
109 force_pull: bool = False,
110 ) -> bool:
111 """
112 Acquire the software container image in the specified dockerRequirement.
113
114 Uses Singularity and returns the success as a bool. Updates the
115 provided dockerRequirement with the specific dockerImageId to the full
116 path of the local image, if found. Likewise the
117 dockerRequirement['dockerPull'] is updated to a docker:// URI if needed.
118 """
119 found = False
120
121 candidates = []
122
123 cache_folder = None
124 if "CWL_SINGULARITY_CACHE" in os.environ:
125 cache_folder = os.environ["CWL_SINGULARITY_CACHE"]
126 elif is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
127 cache_folder = os.environ["SINGULARITY_PULLFOLDER"]
128
129 if (
130 "dockerImageId" not in dockerRequirement
131 and "dockerPull" in dockerRequirement
132 ):
133 match = re.search(
134 pattern=r"([a-z]*://)", string=dockerRequirement["dockerPull"]
135 )
136 img_name = _normalize_image_id(dockerRequirement["dockerPull"])
137 candidates.append(img_name)
138 if is_version_3_or_newer():
139 sif_name = _normalize_sif_id(dockerRequirement["dockerPull"])
140 candidates.append(sif_name)
141 dockerRequirement["dockerImageId"] = sif_name
142 else:
143 dockerRequirement["dockerImageId"] = img_name
144 if not match:
145 dockerRequirement["dockerPull"] = (
146 "docker://" + dockerRequirement["dockerPull"]
147 )
148 elif "dockerImageId" in dockerRequirement:
149 if os.path.isfile(dockerRequirement["dockerImageId"]):
150 found = True
151 candidates.append(dockerRequirement["dockerImageId"])
152 candidates.append(_normalize_image_id(dockerRequirement["dockerImageId"]))
153 if is_version_3_or_newer():
154 candidates.append(_normalize_sif_id(dockerRequirement["dockerPull"]))
155
156 targets = [os.getcwd()]
157 if "CWL_SINGULARITY_CACHE" in os.environ:
158 targets.append(os.environ["CWL_SINGULARITY_CACHE"])
159 if is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
160 targets.append(os.environ["SINGULARITY_PULLFOLDER"])
161 for target in targets:
162 for dirpath, _subdirs, files in os.walk(target):
163 for entry in files:
164 if entry in candidates:
165 path = os.path.join(dirpath, entry)
166 if os.path.isfile(path):
167 _logger.info(
168 "Using local copy of Singularity image found in %s",
169 dirpath,
170 )
171 dockerRequirement["dockerImageId"] = path
172 found = True
173 if (force_pull or not found) and pull_image:
174 cmd = [] # type: List[str]
175 if "dockerPull" in dockerRequirement:
176 if cache_folder:
177 env = os.environ.copy()
178 if is_version_2_6():
179 env["SINGULARITY_PULLFOLDER"] = cache_folder
180 cmd = [
181 "singularity",
182 "pull",
183 "--force",
184 "--name",
185 dockerRequirement["dockerImageId"],
186 str(dockerRequirement["dockerPull"]),
187 ]
188 else:
189 cmd = [
190 "singularity",
191 "pull",
192 "--force",
193 "--name",
194 "{}/{}".format(
195 cache_folder, dockerRequirement["dockerImageId"]
196 ),
197 str(dockerRequirement["dockerPull"]),
198 ]
199
200 _logger.info(str(cmd))
201 check_call(cmd, env=env, stdout=sys.stderr) # nosec
202 dockerRequirement["dockerImageId"] = "{}/{}".format(
203 cache_folder, dockerRequirement["dockerImageId"]
204 )
205 found = True
206 else:
207 cmd = [
208 "singularity",
209 "pull",
210 "--force",
211 "--name",
212 str(dockerRequirement["dockerImageId"]),
213 str(dockerRequirement["dockerPull"]),
214 ]
215 _logger.info(str(cmd))
216 check_call(cmd, stdout=sys.stderr) # nosec
217 found = True
218
219 elif "dockerFile" in dockerRequirement:
220 raise WorkflowException(
221 SourceLine(dockerRequirement, "dockerFile").makeError(
222 "dockerFile is not currently supported when using the "
223 "Singularity runtime for Docker containers."
224 )
225 )
226 elif "dockerLoad" in dockerRequirement:
227 if is_version_3_1_or_newer():
228 if "dockerImageId" in dockerRequirement:
229 name = "{}.sif".format(dockerRequirement["dockerImageId"])
230 else:
231 name = "{}.sif".format(dockerRequirement["dockerLoad"])
232 cmd = [
233 "singularity",
234 "build",
235 name,
236 "docker-archive://{}".format(dockerRequirement["dockerLoad"]),
237 ]
238 _logger.info(str(cmd))
239 check_call(cmd, stdout=sys.stderr) # nosec
240 found = True
241 dockerRequirement["dockerImageId"] = name
242 raise WorkflowException(
243 SourceLine(dockerRequirement, "dockerLoad").makeError(
244 "dockerLoad is not currently supported when using the "
245 "Singularity runtime (version less than 3.1) for Docker containers."
246 )
247 )
248 elif "dockerImport" in dockerRequirement:
249 raise WorkflowException(
250 SourceLine(dockerRequirement, "dockerImport").makeError(
251 "dockerImport is not currently supported when using the "
252 "Singularity runtime for Docker containers."
253 )
254 )
255
256 return found
257
258 def get_from_requirements(
259 self,
260 r: CWLObjectType,
261 pull_image: bool,
262 force_pull: bool,
263 tmp_outdir_prefix: str,
264 ) -> Optional[str]:
265 """
266 Return the filename of the Singularity image.
267
268 (e.g. hello-world-latest.{img,sif}).
269 """
270 if not bool(spawn.find_executable("singularity")):
271 raise WorkflowException("singularity executable is not available")
272
273 if not self.get_image(cast(Dict[str, str], r), pull_image, force_pull):
274 raise WorkflowException(
275 "Container image {} not found".format(r["dockerImageId"])
276 )
277
278 return os.path.abspath(cast(str, r["dockerImageId"]))
279
280 @staticmethod
281 def append_volume(
282 runtime: List[str], source: str, target: str, writable: bool = False
283 ) -> None:
284 runtime.append("--bind")
285 runtime.append(
286 "{}:{}:{}".format(
287 docker_windows_path_adjust(source),
288 docker_windows_path_adjust(target),
289 "rw" if writable else "ro",
290 )
291 )
292
293 def add_file_or_directory_volume(
294 self, runtime: List[str], volume: MapperEnt, host_outdir_tgt: Optional[str]
295 ) -> None:
296 if host_outdir_tgt is not None:
297 # workaround for lack of overlapping mounts in Singularity
298 # revert to daa923d5b0be3819b6ed0e6440e7193e65141052
299 # once https://github.com/sylabs/singularity/issues/1607
300 # is fixed
301 if volume.type == "File":
302 shutil.copy(volume.resolved, host_outdir_tgt)
303 else:
304 shutil.copytree(volume.resolved, host_outdir_tgt)
305 ensure_non_writable(host_outdir_tgt)
306 elif not volume.resolved.startswith("_:"):
307 self.append_volume(runtime, volume.resolved, volume.target)
308
309 def add_writable_file_volume(
310 self,
311 runtime: List[str],
312 volume: MapperEnt,
313 host_outdir_tgt: Optional[str],
314 tmpdir_prefix: str,
315 ) -> None:
316 if host_outdir_tgt is not None:
317 # workaround for lack of overlapping mounts in Singularity
318 # revert to daa923d5b0be3819b6ed0e6440e7193e65141052
319 # once https://github.com/sylabs/singularity/issues/1607
320 # is fixed
321 if self.inplace_update:
322 try:
323 os.link(os.path.realpath(volume.resolved), host_outdir_tgt)
324 except os.error:
325 shutil.copy(volume.resolved, host_outdir_tgt)
326 else:
327 shutil.copy(volume.resolved, host_outdir_tgt)
328 ensure_writable(host_outdir_tgt)
329 elif self.inplace_update:
330 self.append_volume(runtime, volume.resolved, volume.target, writable=True)
331 ensure_writable(volume.resolved)
332 else:
333 file_copy = os.path.join(
334 create_tmp_dir(tmpdir_prefix),
335 os.path.basename(volume.resolved),
336 )
337 shutil.copy(volume.resolved, file_copy)
338 # volume.resolved = file_copy
339 self.append_volume(runtime, file_copy, volume.target, writable=True)
340 ensure_writable(file_copy)
341
342 def add_writable_directory_volume(
343 self,
344 runtime: List[str],
345 volume: MapperEnt,
346 host_outdir_tgt: Optional[str],
347 tmpdir_prefix: str,
348 ) -> None:
349 if volume.resolved.startswith("_:"):
350 if host_outdir_tgt is not None:
351 new_dir = host_outdir_tgt
352 else:
353 new_dir = os.path.join(
354 create_tmp_dir(tmpdir_prefix),
355 os.path.basename(volume.resolved),
356 )
357 os.makedirs(new_dir)
358 else:
359 if host_outdir_tgt is not None:
360 # workaround for lack of overlapping mounts in Singularity
361 # revert to daa923d5b0be3819b6ed0e6440e7193e65141052
362 # once https://github.com/sylabs/singularity/issues/1607
363 # is fixed
364 shutil.copytree(volume.resolved, host_outdir_tgt)
365 ensure_writable(host_outdir_tgt)
366 else:
367 if not self.inplace_update:
368 dir_copy = os.path.join(
369 create_tmp_dir(tmpdir_prefix),
370 os.path.basename(volume.resolved),
371 )
372 shutil.copytree(volume.resolved, dir_copy)
373 source = dir_copy
374 # volume.resolved = dir_copy
375 else:
376 source = volume.resolved
377 self.append_volume(runtime, source, volume.target, writable=True)
378 ensure_writable(source)
379
380 def create_runtime(
381 self, env: MutableMapping[str, str], runtime_context: RuntimeContext
382 ) -> Tuple[List[str], Optional[str]]:
383 """Return the Singularity runtime list of commands and options."""
384 any_path_okay = self.builder.get_requirement("DockerRequirement")[1] or False
385 runtime = [
386 "singularity",
387 "--quiet",
388 "exec",
389 "--contain",
390 "--ipc",
391 ]
392 if _singularity_supports_userns():
393 runtime.append("--userns")
394 else:
395 runtime.append("--pid")
396 if is_version_3_1_or_newer():
397 runtime.append("--home")
398 runtime.append(
399 "{}:{}".format(
400 docker_windows_path_adjust(os.path.realpath(self.outdir)),
401 self.builder.outdir,
402 )
403 )
404 else:
405 runtime.append("--bind")
406 runtime.append(
407 "{}:{}:rw".format(
408 docker_windows_path_adjust(os.path.realpath(self.outdir)),
409 self.builder.outdir,
410 )
411 )
412 runtime.append("--bind")
413 tmpdir = "/tmp" # nosec
414 runtime.append(
415 "{}:{}:rw".format(
416 docker_windows_path_adjust(os.path.realpath(self.tmpdir)), tmpdir
417 )
418 )
419
420 self.add_volumes(
421 self.pathmapper,
422 runtime,
423 any_path_okay=True,
424 secret_store=runtime_context.secret_store,
425 tmpdir_prefix=runtime_context.tmpdir_prefix,
426 )
427 if self.generatemapper is not None:
428 self.add_volumes(
429 self.generatemapper,
430 runtime,
431 any_path_okay=any_path_okay,
432 secret_store=runtime_context.secret_store,
433 tmpdir_prefix=runtime_context.tmpdir_prefix,
434 )
435
436 runtime.append("--pwd")
437 runtime.append("%s" % (docker_windows_path_adjust(self.builder.outdir)))
438
439 if runtime_context.custom_net:
440 raise UnsupportedRequirement(
441 "Singularity implementation does not support custom networking"
442 )
443 elif runtime_context.disable_net:
444 runtime.append("--net")
445
446 env["SINGULARITYENV_TMPDIR"] = tmpdir
447 env["SINGULARITYENV_HOME"] = self.builder.outdir
448
449 for name, value in self.environment.items():
450 env[f"SINGULARITYENV_{name}"] = str(value)
451 return (runtime, None)