Source code for smartsim.settings.containers

# BSD 2-Clause License
#
# Copyright (c) 2021-2024, Hewlett Packard Enterprise
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import shutil
import typing as t

from ..log import get_logger

logger = get_logger(__name__)


class Container:
    """Base class for container types in SmartSim.

    Container types are used to embed all the information needed to
    launch a workload within a container into a single object.

    :param image: local or remote path to container image
    :param args: arguments to container command
    :param mount: paths to mount (bind) from host machine into image.
    :param working_directory: path of the working directory within the container
    """

    def __init__(
        self, image: str, args: str = "", mount: str = "", working_directory: str = ""
    ) -> None:
        # Validate types
        if not isinstance(image, str):
            raise TypeError("image must be a str")
        if not isinstance(args, (str, list)):
            raise TypeError("args must be a str | list")
        if not isinstance(mount, (str, list, dict)):
            raise TypeError("mount must be a str | list | dict")
        if not isinstance(working_directory, str):
            raise TypeError("working_directory must be a str")

        self.image = image
        self.args = args
        self.mount = mount
        self.working_directory = working_directory

    def _containerized_run_command(self, run_command: str) -> str:
        """Return modified run_command with container commands prepended.

        :param run_command: run command from a RunSettings class
        """
        raise NotImplementedError(
            "Containerized run command specification not implemented for this "
            f"Container type: {type(self)}"
        )


[docs]class Singularity(Container): # pylint: disable=abstract-method # todo: determine if _containerized_run_command should be abstract """Singularity (apptainer) container type. To be passed into a ``RunSettings`` class initializer or ``Experiment.create_run_settings``. .. note:: Singularity integration is currently tested with `Apptainer 1.0 <https://apptainer.org/docs/user/1.0/index.html>`_ with slurm and PBS workload managers only. Also, note that user-defined bind paths (``mount`` argument) may be disabled by a `system administrator <https://apptainer.org/docs/admin/1.0/configfiles.html#bind-mount-management>`_ :param image: local or remote path to container image, e.g. ``docker://sylabsio/lolcow`` :param args: arguments to 'singularity exec' command :param mount: paths to mount (bind) from host machine into image. """ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: super().__init__(*args, **kwargs) def _container_cmds(self, default_working_directory: str = "") -> t.List[str]: """Return list of container commands to be inserted before exe. Container members are validated during this call. :raises TypeError: if object members are invalid types """ serialized_args = "" if self.args: # Serialize args into a str if isinstance(self.args, str): serialized_args = self.args elif isinstance(self.args, list): serialized_args = " ".join(self.args) else: raise TypeError("self.args must be a str | list") serialized_mount = "" if self.mount: if isinstance(self.mount, str): serialized_mount = self.mount elif isinstance(self.mount, list): serialized_mount = ",".join(self.mount) elif isinstance(self.mount, dict): paths = [] for host_path, img_path in self.mount.items(): if img_path: paths.append(f"{host_path}:{img_path}") else: paths.append(host_path) serialized_mount = ",".join(paths) else: raise TypeError("self.mount must be str | list | dict") working_directory = default_working_directory if self.working_directory: working_directory = self.working_directory if working_directory not in serialized_mount: if serialized_mount: serialized_mount = ",".join([working_directory, serialized_mount]) else: serialized_mount = working_directory logger.warning( f"Working directory not specified in mount: \n {working_directory}\n" "Automatically adding it to the list of bind points" ) # Find full path to singularity singularity = shutil.which("singularity") # Some systems have singularity available on compute nodes only, # so warn instead of error if not singularity: logger.warning( "Unable to find singularity. Continuing in case singularity is " "available on compute node" ) # Construct containerized launch command cmd_list = [singularity or "singularity", "exec"] if working_directory: cmd_list.extend(["--pwd", working_directory]) if serialized_args: cmd_list.append(serialized_args) if serialized_mount: cmd_list.extend(["--bind", serialized_mount]) cmd_list.append(self.image) return cmd_list