comparison env/lib/python3.9/site-packages/galaxy/tool_util/deps/container_resolvers/mulled.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 """This module describes the :class:`MulledContainerResolver` ContainerResolver plugin."""
2
3 import logging
4 import os
5 import subprocess
6 from typing import NamedTuple, Optional
7
8 from galaxy.util import (
9 safe_makedirs,
10 string_as_bool,
11 unicodify,
12 which,
13 )
14 from galaxy.util.commands import shell
15 from ..container_classes import CONTAINER_CLASSES
16 from ..container_resolvers import (
17 ContainerResolver,
18 )
19 from ..docker_util import build_docker_images_command
20 from ..mulled.mulled_build import (
21 DEFAULT_CHANNELS,
22 ensure_installed,
23 InvolucroContext,
24 mull_targets,
25 )
26 from ..mulled.mulled_build_tool import requirements_to_mulled_targets
27 from ..mulled.util import (
28 mulled_tags_for,
29 split_tag,
30 v1_image_name,
31 v2_image_name,
32 version_sorted,
33 )
34 from ..requirements import (
35 ContainerDescription,
36 DEFAULT_CONTAINER_SHELL,
37 )
38
39 log = logging.getLogger(__name__)
40
41
42 class CachedMulledImageSingleTarget(NamedTuple):
43 package_name: str
44 version: str
45 build: str
46 image_identifier: str
47
48 multi_target: bool = False
49
50
51 class CachedV1MulledImageMultiTarget(NamedTuple):
52 hash: str
53 build: str
54 image_identifier: str
55
56 multi_target: str = "v1"
57
58
59 class CachedV2MulledImageMultiTarget(NamedTuple):
60 image_name: str
61 version_hash: str
62 build: str
63 image_identifier: str
64
65 multi_target: str = "v2"
66
67 @property
68 def package_hash(target):
69 # Make this work for Singularity file name or fully qualified Docker repository
70 # image names.
71 image_name = target.image_name
72 if "/" not in image_name:
73 return image_name
74 else:
75 return image_name.rsplit("/")[-1]
76
77
78 def list_docker_cached_mulled_images(namespace=None, hash_func="v2", resolution_cache=None):
79 cache_key = "galaxy.tool_util.deps.container_resolvers.mulled:cached_images"
80 if resolution_cache is not None and cache_key in resolution_cache:
81 images_and_versions = resolution_cache.get(cache_key)
82 else:
83 command = build_docker_images_command(truncate=True, sudo=False, to_str=False)
84 try:
85 images_and_versions = unicodify(subprocess.check_output(command)).strip().splitlines()
86 except subprocess.CalledProcessError:
87 log.info("Call to `docker images` failed, configured container resolution may be broken")
88 return []
89 images_and_versions = [":".join(l.split()[0:2]) for l in images_and_versions[1:]]
90 if resolution_cache is not None:
91 resolution_cache[cache_key] = images_and_versions
92
93 def output_line_to_image(line):
94 image = identifier_to_cached_target(line, hash_func, namespace=namespace)
95 return image
96
97 name_filter = get_filter(namespace)
98 sorted_images = version_sorted([_ for _ in filter(name_filter, images_and_versions)])
99 raw_images = (output_line_to_image(_) for _ in sorted_images)
100 return [i for i in raw_images if i is not None]
101
102
103 def identifier_to_cached_target(identifier, hash_func, namespace=None):
104 if ":" in identifier:
105 image_name, version = identifier.rsplit(":", 1)
106 else:
107 image_name = identifier
108 version = None
109
110 if not version or version == "latest":
111 version = None
112
113 image = None
114 prefix = ""
115 if namespace is not None:
116 prefix = "quay.io/%s/" % namespace
117 if image_name.startswith(prefix + "mulled-v1-"):
118 if hash_func == "v2":
119 return None
120
121 hash = image_name
122 build = None
123 if version and version.isdigit():
124 build = version
125 image = CachedV1MulledImageMultiTarget(hash, build, identifier)
126 elif image_name.startswith(prefix + "mulled-v2-"):
127 if hash_func == "v1":
128 return None
129
130 version_hash = None
131 build = None
132
133 if version and "-" in version:
134 version_hash, build = version.rsplit("-", 1)
135 elif version.isdigit():
136 version_hash, build = None, version
137 elif version:
138 log.debug("Unparsable mulled image tag encountered [%s]" % version)
139
140 image = CachedV2MulledImageMultiTarget(image_name, version_hash, build, identifier)
141 else:
142 build = None
143 if version and "--" in version:
144 version, build = split_tag(version)
145 if prefix and image_name.startswith(prefix):
146 image_name = image_name[len(prefix):]
147 image = CachedMulledImageSingleTarget(image_name, version, build, identifier)
148 return image
149
150
151 def list_cached_mulled_images_from_path(directory, hash_func="v2"):
152 contents = os.listdir(directory)
153 sorted_images = version_sorted(contents)
154 raw_images = map(lambda name: identifier_to_cached_target(name, hash_func), sorted_images)
155 return [i for i in raw_images if i is not None]
156
157
158 def get_filter(namespace):
159 prefix = "quay.io/" if namespace is None else "quay.io/%s" % namespace
160 return lambda name: name.startswith(prefix) and name.count("/") == 2
161
162
163 def find_best_matching_cached_image(targets, cached_images, hash_func):
164 if len(targets) == 0:
165 return None
166
167 image = None
168 if len(targets) == 1:
169 target = targets[0]
170 for cached_image in cached_images:
171 if cached_image.multi_target:
172 continue
173 if not cached_image.package_name == target.package_name:
174 continue
175 if not target.version or target.version == cached_image.version:
176 image = cached_image
177 break
178 elif hash_func == "v2":
179 name = v2_image_name(targets)
180 if ":" in name:
181 package_hash, version_hash = name.split(":", 2)
182 else:
183 package_hash, version_hash = name, None
184
185 for cached_image in cached_images:
186 if cached_image.multi_target != "v2":
187 continue
188
189 if version_hash is None:
190 # Just match on package hash...
191 if package_hash == cached_image.package_hash:
192 image = cached_image
193 break
194 else:
195 # Match on package and version hash...
196 if package_hash == cached_image.package_hash and version_hash == cached_image.version_hash:
197 image = cached_image
198 break
199
200 elif hash_func == "v1":
201 name = v1_image_name(targets)
202 for cached_image in cached_images:
203 if cached_image.multi_target != "v1":
204 continue
205
206 if name == cached_image.hash:
207 image = cached_image
208 break
209 return image
210
211
212 def docker_cached_container_description(targets, namespace, hash_func="v2", shell=DEFAULT_CONTAINER_SHELL, resolution_cache=None):
213 if len(targets) == 0:
214 return None
215
216 cached_images = list_docker_cached_mulled_images(namespace, hash_func=hash_func, resolution_cache=resolution_cache)
217 image = find_best_matching_cached_image(targets, cached_images, hash_func)
218
219 container = None
220 if image:
221 container = ContainerDescription(
222 image.image_identifier,
223 type="docker",
224 shell=shell,
225 )
226
227 return container
228
229
230 def singularity_cached_container_description(targets, cache_directory, hash_func="v2", shell=DEFAULT_CONTAINER_SHELL):
231 if len(targets) == 0:
232 return None
233
234 if not os.path.exists(cache_directory):
235 return None
236
237 cached_images = list_cached_mulled_images_from_path(cache_directory, hash_func=hash_func)
238 image = find_best_matching_cached_image(targets, cached_images, hash_func)
239
240 container = None
241 if image:
242 container = ContainerDescription(
243 os.path.abspath(os.path.join(cache_directory, image.image_identifier)),
244 type="singularity",
245 shell=shell,
246 )
247
248 return container
249
250
251 def targets_to_mulled_name(targets, hash_func, namespace, resolution_cache=None, session=None):
252 unresolved_cache_key = "galaxy.tool_util.deps.container_resolvers.mulled:unresolved"
253 if resolution_cache is not None:
254 if unresolved_cache_key not in resolution_cache:
255 resolution_cache[unresolved_cache_key] = set()
256 unresolved_cache = resolution_cache.get(unresolved_cache_key)
257 else:
258 unresolved_cache = set()
259
260 mulled_resolution_cache = None
261 if resolution_cache and hasattr(resolution_cache, 'mulled_resolution_cache'):
262 mulled_resolution_cache = resolution_cache.mulled_resolution_cache
263
264 name = None
265
266 def cached_name(cache_key):
267 if mulled_resolution_cache:
268 if cache_key in mulled_resolution_cache:
269 return resolution_cache.get(cache_key)
270 return None
271
272 if len(targets) == 1:
273 target = targets[0]
274 target_version = target.version
275 cache_key = f"ns[{namespace}]__single__{target.package_name}__@__{target_version}"
276 if cache_key in unresolved_cache:
277 return None
278 name = cached_name(cache_key)
279 if name:
280 return name
281
282 tags = mulled_tags_for(namespace, target.package_name, resolution_cache=resolution_cache, session=session)
283
284 if tags:
285 for tag in tags:
286 if '--' in tag:
287 version, _ = split_tag(tag)
288 else:
289 version = tag
290 if target_version and version == target_version:
291 name = f"{target.package_name}:{tag}"
292 break
293
294 else:
295 def first_tag_if_available(image_name):
296 if ":" in image_name:
297 repo_name, tag_prefix = image_name.split(":", 2)
298 else:
299 repo_name = image_name
300 tag_prefix = None
301 tags = mulled_tags_for(namespace, repo_name, tag_prefix=tag_prefix, resolution_cache=resolution_cache, session=session)
302 return tags[0] if tags else None
303
304 if hash_func == "v2":
305 base_image_name = v2_image_name(targets)
306 elif hash_func == "v1":
307 base_image_name = v1_image_name(targets)
308 else:
309 raise Exception("Unimplemented mulled hash_func [%s]" % hash_func)
310
311 cache_key = f"ns[{namespace}]__{hash_func}__{base_image_name}"
312 if cache_key in unresolved_cache:
313 return None
314 name = cached_name(cache_key)
315 if name:
316 return name
317
318 tag = first_tag_if_available(base_image_name)
319 if tag:
320 if ":" in base_image_name:
321 assert hash_func != "v1"
322 # base_image_name of form <package_hash>:<version_hash>, expand tag
323 # to include build number in tag.
324 name = "{}:{}".format(base_image_name.split(":")[0], tag)
325 else:
326 # base_image_name of form <package_hash>, simply add build number
327 # as tag to fully qualify image.
328 name = f"{base_image_name}:{tag}"
329
330 if name and mulled_resolution_cache:
331 mulled_resolution_cache.put(cache_key, name)
332
333 if name is None:
334 unresolved_cache.add(name)
335
336 return name
337
338
339 class CliContainerResolver(ContainerResolver):
340
341 container_type = 'docker'
342 cli = 'docker'
343
344 def __init__(self, *args, **kwargs):
345 self._cli_available = bool(which(self.cli))
346 super().__init__(*args, **kwargs)
347
348 @property
349 def cli_available(self):
350 return self._cli_available
351
352 @cli_available.setter
353 def cli_available(self, value):
354 if not value:
355 log.info('{} CLI not available, cannot list or pull images in Galaxy process. Does not impact kubernetes.'.format(self.cli))
356 self._cli_available = value
357
358
359 class SingularityCliContainerResolver(CliContainerResolver):
360
361 container_type = 'singularity'
362 cli = 'singularity'
363
364 def __init__(self, *args, **kwargs):
365 super().__init__(*args, **kwargs)
366 self.cache_directory = kwargs.get("cache_directory", os.path.join(kwargs['app_info'].container_image_cache_path, "singularity", "mulled"))
367 safe_makedirs(self.cache_directory)
368
369
370 class CachedMulledDockerContainerResolver(CliContainerResolver):
371
372 resolver_type = "cached_mulled"
373 shell = '/bin/bash'
374
375 def __init__(self, app_info=None, namespace="biocontainers", hash_func="v2", **kwds):
376 super().__init__(app_info=app_info, **kwds)
377 self.namespace = namespace
378 self.hash_func = hash_func
379
380 def resolve(self, enabled_container_types, tool_info, **kwds):
381 if not self.cli_available or tool_info.requires_galaxy_python_environment or self.container_type not in enabled_container_types:
382 return None
383
384 targets = mulled_targets(tool_info)
385 resolution_cache = kwds.get("resolution_cache")
386 return docker_cached_container_description(targets, self.namespace, hash_func=self.hash_func, shell=self.shell, resolution_cache=resolution_cache)
387
388 def __str__(self):
389 return "CachedMulledDockerContainerResolver[namespace=%s]" % self.namespace
390
391
392 class CachedMulledSingularityContainerResolver(SingularityCliContainerResolver):
393
394 resolver_type = "cached_mulled_singularity"
395 shell = '/bin/bash'
396
397 def __init__(self, app_info=None, hash_func="v2", **kwds):
398 super().__init__(app_info=app_info, **kwds)
399 self.hash_func = hash_func
400
401 def resolve(self, enabled_container_types, tool_info, **kwds):
402 if tool_info.requires_galaxy_python_environment or self.container_type not in enabled_container_types:
403 return None
404
405 targets = mulled_targets(tool_info)
406 return singularity_cached_container_description(targets, self.cache_directory, hash_func=self.hash_func, shell=self.shell)
407
408 def __str__(self):
409 return "CachedMulledSingularityContainerResolver[cache_directory=%s]" % self.cache_directory
410
411
412 class MulledDockerContainerResolver(CliContainerResolver):
413 """Look for mulled images matching tool dependencies."""
414
415 resolver_type = "mulled"
416 shell = '/bin/bash'
417 protocol: Optional[str] = None
418
419 def __init__(self, app_info=None, namespace="biocontainers", hash_func="v2", auto_install=True, **kwds):
420 super().__init__(app_info=app_info, **kwds)
421 self.namespace = namespace
422 self.hash_func = hash_func
423 self.auto_install = string_as_bool(auto_install)
424
425 def cached_container_description(self, targets, namespace, hash_func, resolution_cache):
426 try:
427 return docker_cached_container_description(targets, namespace, hash_func, resolution_cache)
428 except subprocess.CalledProcessError:
429 # We should only get here if a docker binary is available, but command quits with a non-zero exit code,
430 # e.g if the docker daemon is not available
431 log.exception('An error occured while listing cached docker image. Docker daemon may need to be restarted.')
432 return None
433
434 def pull(self, container):
435 if self.cli_available:
436 command = container.build_pull_command()
437 shell(command)
438
439 @property
440 def can_list_containers(self):
441 return self.cli_available
442
443 def resolve(self, enabled_container_types, tool_info, install=False, session=None, **kwds):
444 resolution_cache = kwds.get("resolution_cache")
445 if tool_info.requires_galaxy_python_environment or self.container_type not in enabled_container_types:
446 return None
447
448 targets = mulled_targets(tool_info)
449 if len(targets) == 0:
450 return None
451
452 name = targets_to_mulled_name(targets=targets, hash_func=self.hash_func, namespace=self.namespace, resolution_cache=resolution_cache, session=session)
453 if name:
454 container_id = f"quay.io/{self.namespace}/{name}"
455 if self.protocol:
456 container_id = f"{self.protocol}{container_id}"
457 container_description = ContainerDescription(
458 container_id,
459 type=self.container_type,
460 shell=self.shell,
461 )
462 if self.can_list_containers:
463 if install and not self.cached_container_description(
464 targets,
465 namespace=self.namespace,
466 hash_func=self.hash_func,
467 resolution_cache=resolution_cache,
468 ):
469 destination_info = {}
470 destination_for_container_type = kwds.get('destination_for_container_type')
471 if destination_for_container_type:
472 destination_info = destination_for_container_type(self.container_type)
473 container = CONTAINER_CLASSES[self.container_type](container_description.identifier,
474 self.app_info,
475 tool_info,
476 destination_info,
477 {},
478 container_description)
479 self.pull(container)
480 if not self.auto_install:
481 container_description = self.cached_container_description(
482 targets,
483 namespace=self.namespace,
484 hash_func=self.hash_func,
485 resolution_cache=resolution_cache,
486 ) or container_description
487 return container_description
488
489 def __str__(self):
490 return "MulledDockerContainerResolver[namespace=%s]" % self.namespace
491
492
493 class MulledSingularityContainerResolver(SingularityCliContainerResolver, MulledDockerContainerResolver):
494
495 resolver_type = "mulled_singularity"
496 protocol = 'docker://'
497
498 def __init__(self, app_info=None, namespace="biocontainers", hash_func="v2", auto_install=True, **kwds):
499 super().__init__(app_info=app_info, **kwds)
500 self.namespace = namespace
501 self.hash_func = hash_func
502 self.auto_install = string_as_bool(auto_install)
503
504 def cached_container_description(self, targets, namespace, hash_func, resolution_cache):
505 return singularity_cached_container_description(targets,
506 cache_directory=self.cache_directory,
507 hash_func=hash_func)
508
509 @property
510 def can_list_containers(self):
511 # Only needs access to path, doesn't require CLI
512 return True
513
514 def pull(self, container):
515 if self.cli_available:
516 cmds = container.build_mulled_singularity_pull_command(cache_directory=self.cache_directory, namespace=self.namespace)
517 shell(cmds=cmds)
518
519 def __str__(self):
520 return "MulledSingularityContainerResolver[namespace=%s]" % self.namespace
521
522
523 class BuildMulledDockerContainerResolver(CliContainerResolver):
524 """Build for Docker mulled images matching tool dependencies."""
525
526 resolver_type = "build_mulled"
527 shell = '/bin/bash'
528 builds_on_resolution = True
529
530 def __init__(self, app_info=None, namespace="local", hash_func="v2", auto_install=True, **kwds):
531 super().__init__(app_info=app_info, **kwds)
532 self._involucro_context_kwds = {
533 'involucro_bin': self._get_config_option("involucro_path", None)
534 }
535 self.namespace = namespace
536 self.hash_func = hash_func
537 self.auto_install = string_as_bool(auto_install)
538 self._mulled_kwds = {
539 'namespace': namespace,
540 'channels': self._get_config_option("mulled_channels", DEFAULT_CHANNELS),
541 'hash_func': self.hash_func,
542 'command': 'build-and-test',
543 }
544 self.auto_init = self._get_config_option("involucro_auto_init", True)
545
546 def resolve(self, enabled_container_types, tool_info, install=False, **kwds):
547 if tool_info.requires_galaxy_python_environment or self.container_type not in enabled_container_types:
548 return None
549
550 targets = mulled_targets(tool_info)
551 if len(targets) == 0:
552 return None
553 if self.auto_install or install:
554 mull_targets(
555 targets,
556 involucro_context=self._get_involucro_context(),
557 **self._mulled_kwds
558 )
559 return docker_cached_container_description(targets, self.namespace, hash_func=self.hash_func, shell=self.shell)
560
561 def _get_involucro_context(self):
562 involucro_context = InvolucroContext(**self._involucro_context_kwds)
563 self.enabled = ensure_installed(involucro_context, self.auto_init)
564 return involucro_context
565
566 def __str__(self):
567 return "BuildDockerContainerResolver[namespace=%s]" % self.namespace
568
569
570 class BuildMulledSingularityContainerResolver(SingularityCliContainerResolver):
571 """Build for Singularity mulled images matching tool dependencies."""
572
573 resolver_type = "build_mulled_singularity"
574 shell = '/bin/bash'
575 builds_on_resolution = True
576
577 def __init__(self, app_info=None, hash_func="v2", auto_install=True, **kwds):
578 super().__init__(app_info=app_info, **kwds)
579 self._involucro_context_kwds = {
580 'involucro_bin': self._get_config_option("involucro_path", None)
581 }
582 self.hash_func = hash_func
583 self.auto_install = string_as_bool(auto_install)
584 self._mulled_kwds = {
585 'channels': self._get_config_option("mulled_channels", DEFAULT_CHANNELS),
586 'hash_func': self.hash_func,
587 'command': 'build-and-test',
588 'singularity': True,
589 'singularity_image_dir': self.cache_directory,
590 }
591 self.auto_init = self._get_config_option("involucro_auto_init", True)
592
593 def resolve(self, enabled_container_types, tool_info, install=False, **kwds):
594 if tool_info.requires_galaxy_python_environment or self.container_type not in enabled_container_types:
595 return None
596
597 targets = mulled_targets(tool_info)
598 if len(targets) == 0:
599 return None
600
601 if self.auto_install or install:
602 mull_targets(
603 targets,
604 involucro_context=self._get_involucro_context(),
605 **self._mulled_kwds
606 )
607 return singularity_cached_container_description(targets, self.cache_directory, hash_func=self.hash_func, shell=self.shell)
608
609 def _get_involucro_context(self):
610 involucro_context = InvolucroContext(**self._involucro_context_kwds)
611 self.enabled = ensure_installed(involucro_context, self.auto_init)
612 return involucro_context
613
614 def __str__(self):
615 return "BuildSingularityContainerResolver[cache_directory=%s]" % self.cache_directory
616
617
618 def mulled_targets(tool_info):
619 return requirements_to_mulled_targets(tool_info.requirements)
620
621
622 __all__ = (
623 "CachedMulledDockerContainerResolver",
624 "CachedMulledSingularityContainerResolver",
625 "MulledDockerContainerResolver",
626 "MulledSingularityContainerResolver",
627 "BuildMulledDockerContainerResolver",
628 "BuildMulledSingularityContainerResolver",
629 )