diff --git a/reframe/core/containers.py b/reframe/core/containers.py new file mode 100644 index 0000000000..4db50b6dd9 --- /dev/null +++ b/reframe/core/containers.py @@ -0,0 +1,109 @@ +import abc + +import reframe.core.fields as fields +import reframe.utility.typecheck as typ +from reframe.core.exceptions import ContainerError + + +class ContainerPlatform(abc.ABC): + """The abstract base class of any container platform. + + Concrete container platforms inherit from this class and must override the + :func:`emit_prepare_cmds` and :func:`emit_launch_cmds` abstract functions. + """ + + registry = fields.TypedField('registry', str, type(None)) + image = fields.TypedField('image', str, type(None)) + requires_mpi = fields.TypedField('requires_mpi', bool) + commands = fields.TypedField('commands', typ.List[str]) + mount_points = fields.TypedField('mount_points', + typ.List[typ.Tuple[str, str]]) + workdir = fields.TypedField('workdir', str, type(None)) + + def __init__(self): + self.registry = None + self.image = None + self.requires_mpi = False + self.commands = [] + self.mount_points = [] + self.workdir = None + + @abc.abstractmethod + def emit_prepare_cmds(self): + """Returns commands that are necessary before running with this + container platform. + + :raises: `ContainerError` in case of errors. + + .. note: + This method is relevant only to developers of new container + platforms. + """ + + @abc.abstractmethod + def emit_launch_cmds(self): + """Returns the command for running with this container platform. + + :raises: `ContainerError` in case of errors. + + .. note: + This method is relevant only to developers of new container + platforms. + """ + if self.registry: + self.image = '/'.join([self.registry, self.image]) + + @abc.abstractmethod + def validate(self): + """Validates this container platform. + + :raises: `ContainerError` in case of errors. + + .. note: + This method is relevant only to developers of new container + platforms. + """ + if self.image is None: + raise ContainerError('no image specified') + + if not self.commands: + raise ContainerError('no commands specified') + + +class Docker(ContainerPlatform): + """An implementation of ContainerPlatform to run containers with Docker.""" + + def emit_prepare_cmds(self): + pass + + def emit_launch_cmds(self): + super().emit_launch_cmds() + docker_opts = ['-v "%s":"%s"' % mp for mp in self.mount_points] + run_cmd = 'docker run %s %s bash -c ' % (' '.join(docker_opts), + self.image) + return run_cmd + "'" + '; '.join( + ['cd ' + self.workdir] + self.commands) + "'" + + def validate(self): + super().validate() + + +class ContainerPlatformField(fields.TypedField): + """A field representing a container platforms. + + You may either assign an instance of :class:`ContainerPlatform:` or a + string representing the name of the concrete class of a container platform. + """ + + def __init__(self, fieldname, *other_types): + super().__init__(fieldname, ContainerPlatform, *other_types) + + def __set__(self, obj, value): + if isinstance(value, str): + try: + value = globals()[value]() + except KeyError: + raise ValueError( + 'unknown container platform: %s' % value) from None + + super().__set__(obj, value) diff --git a/reframe/core/exceptions.py b/reframe/core/exceptions.py index 91e46528c1..970aed33c6 100644 --- a/reframe/core/exceptions.py +++ b/reframe/core/exceptions.py @@ -108,6 +108,10 @@ class BuildSystemError(ReframeError): """Raised when a build system is not configured properly.""" +class ContainerError(ReframeError): + """Raised when a container platform is not configured properly.""" + + class BuildError(ReframeError): """Raised when a build fails.""" diff --git a/unittests/test_containers.py b/unittests/test_containers.py new file mode 100644 index 0000000000..96b5f2f346 --- /dev/null +++ b/unittests/test_containers.py @@ -0,0 +1,68 @@ +import abc +import unittest + +import pytest +import reframe.core.containers as containers +from reframe.core.exceptions import ContainerError + + +class _ContainerPlatformTest(abc.ABC): + @abc.abstractmethod + def create_container_platform(self): + pass + + @property + @abc.abstractmethod + def exp_cmd_mount_points(self): + pass + + @property + @abc.abstractmethod + def exp_cmd_custom_registry(self): + pass + + def setUp(self): + self.container_platform = self.create_container_platform() + + def test_mount_points(self): + self.container_platform.image = 'name:tag' + self.container_platform.mount_points = [('/path/one', '/one'), + ('/path/two', '/two')] + self.container_platform.commands = ['cmd1', 'cmd2'] + self.container_platform.workdir = '/stagedir' + assert (self.exp_cmd_mount_points == + self.container_platform.emit_launch_cmds()) + + def test_missing_image(self): + self.container_platform.commands = ['cmd'] + with pytest.raises(ContainerError): + self.container_platform.validate() + + def test_missing_commands(self): + self.container_platform.image = 'name:tag' + with pytest.raises(ContainerError): + self.container_platform.validate() + + def test_custom_registry(self): + self.container_platform.registry = 'registry/custom' + self.container_platform.image = 'name:tag' + self.container_platform.commands = ['cmd'] + self.container_platform.mount_points = [('/path/one', '/one')] + self.container_platform.workdir = '/stagedir' + assert (self.exp_cmd_custom_registry == + self.container_platform.emit_launch_cmds()) + + +class TestDocker(_ContainerPlatformTest, unittest.TestCase): + def create_container_platform(self): + return containers.Docker() + + @property + def exp_cmd_mount_points(self): + return ('docker run -v "/path/one":"/one" -v "/path/two":"/two" ' + "name:tag bash -c 'cd /stagedir; cmd1; cmd2'") + + @property + def exp_cmd_custom_registry(self): + return ('docker run -v "/path/one":"/one" registry/custom/name:tag ' + "bash -c 'cd /stagedir; cmd'")