Source code for reframe.core.containers
# Copyright 2016-2021 Swiss National Supercomputing Centre (CSCS/ETH Zurich)
# ReFrame Project Developers. See the top-level LICENSE file for details.
#
# SPDX-License-Identifier: BSD-3-Clause
import abc
import reframe.core.fields as fields
import reframe.utility.typecheck as typ
from reframe.core.exceptions import ContainerError
_STAGEDIR_MOUNT = '/rfm_workdir'
[docs]class ContainerPlatform(abc.ABC):
'''The abstract base class of any container platform.'''
#: The default mount location of the test case stage directory inside the
#: container
#: The container image to be used for running the test.
#:
#: :type: :class:`str` or :class:`None`
#: :default: :class:`None`
image = fields.TypedField(str, type(None))
#: The command to be executed within the container.
#:
#: If no command is given, then the default command of the corresponding
#: container image is going to be executed.
#:
#: .. versionadded:: 3.5.0
#: Changed the attribute name from `commands` to `command` and its type
#: to a string.
#:
#: :type: :class:`str` or :class:`None`
#: :default: :class:`None`
command = fields.TypedField(str, type(None))
_commands = fields.TypedField(typ.List[str])
#: The commands to be executed within the container.
#:
#: .. deprecated:: 3.5.0
#: Please use the `command` field instead.
#:
#: :type: :class:`list[str]`
#: :default: ``[]``
commands = fields.DeprecatedField(
_commands,
'The `commands` field is deprecated, please use the `command` field '
'to set the command to be executed by the container.',
fields.DeprecatedField.OP_SET, from_version='3.5.0'
)
#: Pull the container image before running.
#:
#: This does not have any effect for the `Singularity` container platform.
#:
#: .. versionadded:: 3.5
#:
#: :type: :class:`bool`
#: :default: ``True``
pull_image = fields.TypedField(bool)
#: List of mount point pairs for directories to mount inside the container.
#:
#: Each mount point is specified as a tuple of
#: ``(/path/in/host, /path/in/container)``. The stage directory of the
#: ReFrame test is always mounted under ``/rfm_workdir`` inside the
#: container, independelty of this field.
#:
#: :type: :class:`list[tuple[str, str]]`
#: :default: ``[]``
mount_points = fields.TypedField(typ.List[typ.Tuple[str, str]])
#: Additional options to be passed to the container runtime when executed.
#:
#: :type: :class:`list[str]`
#: :default: ``[]``
options = fields.TypedField(typ.List[str])
_workdir = fields.TypedField(str, type(None))
#: The working directory of ReFrame inside the container.
#:
#: This is the directory where the test's stage directory is mounted inside
#: the container. This directory is always mounted regardless if
#: :attr:`mount_points` is set or not.
#:
#: .. deprecated:: 3.5
#: Please use the `options` field to set the working directory.
#:
#: :type: :class:`str`
#: :default: ``/rfm_workdir``
workdir = fields.DeprecatedField(
_workdir,
'The `workdir` field is deprecated, please use the `options` field to '
'set the container working directory',
fields.DeprecatedField.OP_SET, from_version='3.5.0'
)
def __init__(self):
self.image = None
self.command = None
# NOTE: Here we set the target fields directly to avoid the deprecation
# warnings
self._commands = []
self._workdir = _STAGEDIR_MOUNT
self.mount_points = []
self.options = []
self.pull_image = True
@abc.abstractmethod
def emit_prepare_commands(self, stagedir):
'''Returns commands for preparing this container for running.
Such a command could be for pulling the container image from a
repository.
.. note:
This method is relevant only to developers of new container
platform backends.
:meta private:
:arg stagedir: The stage directory of the test.
'''
@abc.abstractmethod
def launch_command(self, stagedir):
'''Returns the command for running :attr:`commands` with this container
platform.
.. note:
This method is relevant only to developers of new container
platforms.
:meta private:
:arg stagedir: The stage directory of the test.
'''
def validate(self):
if self.image is None:
raise ContainerError('no image specified')
def __str__(self):
return type(self).__name__
def __rfm_json_encode__(self):
return str(self)
[docs]class Docker(ContainerPlatform):
'''Container platform backend for running containers with `Docker
<https://www.docker.com/>`__.'''
def emit_prepare_commands(self, stagedir):
return [f'docker pull {self.image}'] if self.pull_image else []
def launch_command(self, stagedir):
super().launch_command(stagedir)
mount_points = self.mount_points + [(stagedir, _STAGEDIR_MOUNT)]
run_opts = [f'-v "{mp[0]}":"{mp[1]}"' for mp in mount_points]
run_opts += self.options
if self.command:
return (f'docker run --rm {" ".join(run_opts)} '
f'{self.image} {self.command}')
if self.commands:
return (f"docker run --rm {' '.join(run_opts)} {self.image} "
f"bash -c 'cd {self.workdir}; {'; '.join(self.commands)}'")
return f'docker run --rm {" ".join(run_opts)} {self.image}'
[docs]class Sarus(ContainerPlatform):
'''Container platform backend for running containers with `Sarus
<https://sarus.readthedocs.io>`__.'''
#: Enable MPI support when launching the container.
#:
#: :type: boolean
#: :default: :class:`False`
with_mpi = fields.TypedField(bool)
def __init__(self):
super().__init__()
self.with_mpi = False
self._command = 'sarus'
def emit_prepare_commands(self, stagedir):
# The format that Sarus uses to call the images is
# <reposerver>/<user>/<image>:<tag>. If an image was loaded
# locally from a tar file, the <reposerver> is 'load'.
if not self.pull_image or self.image.startswith('load/'):
return []
else:
return [f'{self._command} pull {self.image}']
def launch_command(self, stagedir):
super().launch_command(stagedir)
mount_points = self.mount_points + [(stagedir, _STAGEDIR_MOUNT)]
run_opts = [f'--mount=type=bind,source="{mp[0]}",destination="{mp[1]}"'
for mp in mount_points]
if self.with_mpi:
run_opts.append('--mpi')
run_opts += self.options
if self.command:
return (f'{self._command} run {" ".join(run_opts)} {self.image} '
f'{self.command}')
if self.commands:
return (f"{self._command} run {' '.join(run_opts)} {self.image} "
f"bash -c 'cd {self.workdir}; {'; '.join(self.commands)}'")
return f'{self._command} run {" ".join(run_opts)} {self.image}'
[docs]class Shifter(Sarus):
'''Container platform backend for running containers with `Shifter
<https://www.nersc.gov/research-and-development/user-defined-images/>`__.
'''
def __init__(self):
super().__init__()
self._command = 'shifter'
[docs]class Singularity(ContainerPlatform):
'''Container platform backend for running containers with `Singularity
<https://sylabs.io/>`__.'''
#: Enable CUDA support when launching the container.
#:
#: :type: boolean
#: :default: :class:`False`
with_cuda = fields.TypedField(bool)
def __init__(self):
super().__init__()
self.with_cuda = False
def emit_prepare_commands(self, stagedir):
return []
def launch_command(self, stagedir):
super().launch_command(stagedir)
mount_points = self.mount_points + [(stagedir, _STAGEDIR_MOUNT)]
run_opts = [f'-B"{mp[0]}:{mp[1]}"' for mp in mount_points]
if self.with_cuda:
run_opts.append('--nv')
run_opts += self.options
if self.command:
return (f'singularity exec {" ".join(run_opts)} '
f'{self.image} {self.command}')
if self.commands:
return (f"singularity exec {' '.join(run_opts)} {self.image} "
f"bash -c 'cd {self.workdir}; {'; '.join(self.commands)}'")
return f'singularity run {" ".join(run_opts)} {self.image}'
class ContainerPlatformField(fields.TypedField):
def __init__(self, *other_types):
super().__init__(ContainerPlatform, *other_types)
def __set__(self, obj, value):
if isinstance(value, str):
try:
value = globals()[value]()
except KeyError:
raise ValueError(
f'unknown container platform: {value}') from None
super().__set__(obj, value)