comparison env/lib/python3.9/site-packages/galaxy/containers/__init__.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 """
2 Interfaces to containerization software
3 """
4
5 import errno
6 import inspect
7 import logging
8 import shlex
9 import subprocess
10 import sys
11 import uuid
12 from abc import (
13 ABCMeta,
14 abstractmethod,
15 abstractproperty
16 )
17 from typing import Any, Dict, NamedTuple, Optional, Type
18
19 import yaml
20
21 from galaxy.exceptions import ContainerCLIError
22 from galaxy.util.submodules import import_submodules
23
24
25 DEFAULT_CONTAINER_TYPE = 'docker'
26 DEFAULT_CONF = {'_default_': {'type': DEFAULT_CONTAINER_TYPE}}
27
28 log = logging.getLogger(__name__)
29
30
31 class ContainerPort(NamedTuple):
32 """Named tuple representing ports published by a container, with attributes"""
33 port: int # Port number (inside the container)
34 protocol: str # Port protocol, either ``tcp`` or ``udp``
35 hostaddr: str # Address or hostname where the published port can be accessed
36 hostport: int # Published port number on which the container can be accessed
37
38
39 class ContainerVolume(metaclass=ABCMeta):
40
41 valid_modes = frozenset({"ro", "rw"})
42
43 def __init__(self, path, host_path=None, mode=None):
44 self.path = path
45 self.host_path = host_path
46 self.mode = mode
47 if mode and not self.mode_is_valid:
48 raise ValueError("Invalid container volume mode: %s" % mode)
49
50 @abstractmethod
51 def from_str(cls, as_str):
52 """Classmethod to convert from this container type's string representation.
53
54 :param as_str: string representation of volume
55 :type as_str: str
56 """
57
58 @abstractmethod
59 def __str__(self):
60 """Return this container type's string representation of the volume.
61 """
62
63 @abstractmethod
64 def to_native(self):
65 """Return this container type's native representation of the volume.
66 """
67
68 @property
69 def mode_is_valid(self):
70 return self.mode in self.valid_modes
71
72
73 class Container(metaclass=ABCMeta):
74
75 def __init__(self, interface, id, name=None, **kwargs):
76 """
77
78 :param interface: Container interface for the given container type
79 :type interface: :class:`ContainerInterface` subclass instance
80 :param id: Container identifier
81 :type id: str
82 :param name: Container name
83 :type name: str
84
85 """
86 self._interface = interface
87 self._id = id
88 self._name = name
89
90 @property
91 def id(self):
92 """The container's id"""
93 return self._id
94
95 @property
96 def name(self):
97 """The container's name"""
98 return self._name
99
100 @abstractmethod
101 def from_id(cls, interface, id):
102 """Classmethod to create an instance of Container from the container system's id for the given container type.
103
104 :param interface: Container insterface for the given id
105 :type interface: :class:`ContainerInterface` subclass instance
106 :param id: Container identifier
107 :type id: str
108 :returns: Container object
109 :rtype: :class:`Container` subclass instance
110 """
111
112 @abstractproperty
113 def ports(self):
114 """Attribute for accessing details of ports published by the container.
115
116 :returns: Port details
117 :rtype: list of :class:`ContainerPort` s
118 """
119
120 @abstractproperty
121 def address(self):
122 """Attribute for accessing the address or hostname where published ports can be accessed.
123
124 :returns: Hostname or IP address
125 :rtype: str
126 """
127
128 @abstractmethod
129 def is_ready(self):
130 """Indicate whether or not the container is "ready" (up, available, running).
131
132 :returns: True if ready, else False
133 :rtpe: bool
134 """
135
136 def map_port(self, port):
137 """Map a given container port to a host address/port.
138
139 For legacy reasons, if port is ``None``, the first port (if any) will be returned
140
141 :param port: Container port to map
142 :type port: int
143 :returns: Mapping to host address/port for given container port
144 :rtype: :class:`ContainerPort` instance
145 """
146 mapping = None
147 ports = self.ports or []
148 for mapping in ports:
149 if port == mapping.port:
150 return mapping
151 if port is None:
152 log.warning("Container %s (%s): Don't know how to map ports to containers with multiple exposed ports "
153 "when a specific port is not requested. Arbitrarily choosing first: %s",
154 self.name, self.id, mapping)
155 return mapping
156 else:
157 if port is None:
158 log.warning("Container %s (%s): No exposed ports found!", self.name, self.id)
159 else:
160 log.warning("Container %s (%s): No mapping found for port: %s", self.name, self.id, port)
161 return None
162
163
164 class ContainerInterface(metaclass=ABCMeta):
165
166 container_type: Optional[str] = None
167 container_class: Optional[Type[Container]] = None
168 volume_class = Optional[Type[ContainerVolume]]
169 conf_defaults: Dict[str, Optional[Any]] = {
170 'name_prefix': 'galaxy_',
171 }
172 option_map: Dict[str, Dict] = {}
173 publish_port_list_required = False
174 supports_volumes = True
175
176 def __init__(self, conf, key, containers_config_file):
177 self._key = key
178 self._containers_config_file = containers_config_file
179 mro = reversed(self.__class__.__mro__)
180 next(mro)
181 self._conf = ContainerInterfaceConfig()
182 for c in mro:
183 self._conf.update(c.conf_defaults)
184 self._conf.update(conf)
185 self.validate_config()
186
187 def _normalize_command(self, command):
188 if isinstance(command, str):
189 command = shlex.split(command)
190 return command
191
192 def _guess_kwopt_type(self, val):
193 opttype = 'string'
194 if isinstance(val, bool):
195 opttype = 'boolean'
196 elif isinstance(val, list):
197 opttype = 'list'
198 try:
199 if isinstance(val[0], tuple) and len(val[0]) == 3:
200 opttype = 'list_of_kovtrips'
201 except IndexError:
202 pass
203 elif isinstance(val, dict):
204 opttype = 'list_of_kvpairs'
205 return opttype
206
207 def _guess_kwopt_flag(self, opt):
208 return '--%s' % opt.replace('_', '-')
209
210 def _stringify_kwopts(self, kwopts):
211 opts = []
212 for opt, val in kwopts.items():
213 try:
214 optdef = self.option_map[opt]
215 except KeyError:
216 optdef = {
217 'flag': self._guess_kwopt_flag(opt),
218 'type': self._guess_kwopt_type(val),
219 }
220 log.warning("option '%s' not in %s.option_map, guessing flag '%s' type '%s'",
221 opt, self.__class__.__name__, optdef['flag'], optdef['type'])
222 opts.append(getattr(self, '_stringify_kwopt_' + optdef['type'])(optdef['flag'], val))
223 return ' '.join(opts)
224
225 def _stringify_kwopt_boolean(self, flag, val):
226 """
227 """
228 return '{flag}={value}'.format(flag=flag, value=str(val).lower())
229
230 def _stringify_kwopt_string(self, flag, val):
231 """
232 """
233 return '{flag} {value}'.format(flag=flag, value=shlex.quote(str(val)))
234
235 def _stringify_kwopt_list(self, flag, val):
236 """
237 """
238 if isinstance(val, str):
239 return self._stringify_kwopt_string(flag, val)
240 return ' '.join('{flag} {value}'.format(flag=flag, value=shlex.quote(str(v))) for v in val)
241
242 def _stringify_kwopt_list_of_kvpairs(self, flag, val):
243 """
244 """
245 l = []
246 if isinstance(val, list):
247 # ['foo=bar', 'baz=quux']
248 l = val
249 else:
250 # {'foo': 'bar', 'baz': 'quux'}
251 for k, v in dict(val).items():
252 l.append(f'{k}={v}')
253 return self._stringify_kwopt_list(flag, l)
254
255 def _stringify_kwopt_list_of_kovtrips(self, flag, val):
256 """
257 """
258 if isinstance(val, str):
259 return self._stringify_kwopt_string(flag, val)
260 l = []
261 for k, o, v in val:
262 l.append(f'{k}{o}{v}')
263 return self._stringify_kwopt_list(flag, l)
264
265 def _run_command(self, command, verbose=False):
266 if verbose:
267 log.debug('running command: [%s]', command)
268 command_list = self._normalize_command(command)
269 p = subprocess.Popen(command_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)
270 stdout, stderr = p.communicate()
271 if p.returncode == 0:
272 return stdout.strip()
273 else:
274 msg = f"Command '{command}' returned non-zero exit status {p.returncode}"
275 log.error(msg + ': ' + stderr.strip())
276 raise ContainerCLIError(
277 msg,
278 stdout=stdout.strip(),
279 stderr=stderr.strip(),
280 returncode=p.returncode,
281 command=command,
282 subprocess_command=command_list)
283
284 @property
285 def key(self):
286 return self._key
287
288 @property
289 def containers_config_file(self):
290 return self._containers_config_file
291
292 def get_container(self, container_id):
293 return self.container_class.from_id(self, container_id)
294
295 def set_kwopts_name(self, kwopts):
296 if self._name_prefix is not None:
297 name = '{prefix}{name}'.format(
298 prefix=self._name_prefix,
299 name=kwopts.get('name', uuid.uuid4().hex)
300 )
301 kwopts['name'] = name
302
303 def validate_config(self):
304 """
305 """
306 self._name_prefix = self._conf.name_prefix
307
308 @abstractmethod
309 def run_in_container(self, command, image=None, **kwopts):
310 """
311 """
312
313
314 class ContainerInterfaceConfig(dict):
315
316 def __setattr__(self, name, value):
317 self[name] = value
318
319 def __getattr__(self, name):
320 try:
321 return self[name]
322 except KeyError:
323 raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
324
325 def get(self, name, default=None):
326 try:
327 return self[name]
328 except KeyError:
329 return default
330
331
332 def build_container_interfaces(containers_config_file, containers_conf=None):
333 """Build :class:`ContainerInterface`s. Pass ``containers_conf`` to avoid rereading the config file.
334
335 :param containers_config_file: Filename of containers_conf.yml
336 :type containers_config_file: str
337 :param containers_conf: Optional containers conf (as read from containers_conf.yml), will be used in place
338 of containers_config_file
339 :type containers_conf: dict
340 :returns: Instantiated container interfaces with keys corresponding to ``containers`` keys
341 :rtype: dict of :class:`ContainerInterface` subclass instances
342 """
343 if not containers_conf:
344 containers_conf = parse_containers_config(containers_config_file)
345 interface_classes = _get_interface_modules()
346 interfaces = {}
347 for k, conf in containers_conf.items():
348 container_type = conf.get('type', DEFAULT_CONTAINER_TYPE)
349 assert container_type in interface_classes, "unknown container interface type: %s" % container_type
350 interfaces[k] = interface_classes[container_type](conf, k, containers_config_file)
351 return interfaces
352
353
354 def parse_containers_config(containers_config_file):
355 """Parse a ``containers_conf.yml`` and return the contents of its ``containers`` dictionary.
356
357 :param containers_config_file: Filename of containers_conf.yml
358 :type containers_config_file: str
359 :returns: Contents of the dictionary under the ``containers`` key
360 :rtype: dict
361 """
362 conf = DEFAULT_CONF.copy()
363 try:
364 with open(containers_config_file) as fh:
365 c = yaml.safe_load(fh)
366 conf.update(c.get('containers', {}))
367 except OSError as exc:
368 if exc.errno == errno.ENOENT:
369 log.debug("config file '%s' does not exist, running with default config", containers_config_file)
370 else:
371 raise
372 return conf
373
374
375 def _get_interface_modules():
376 interfaces = []
377 modules = import_submodules(sys.modules[__name__])
378 for module in modules:
379 module_names = [getattr(module, _) for _ in dir(module)]
380 classes = [_ for _ in module_names if inspect.isclass(_) and
381 not _ == ContainerInterface and issubclass(_, ContainerInterface)]
382 interfaces.extend(classes)
383 return {x.container_type: x for x in interfaces}