| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| # Authors: |
| # Andrew Leeming <andrew.leeming@codethink.co.uk> |
| # Tristan Van Berkom <tristan.vanberkom@codethink.co.uk> |
| """ |
| Sandbox - The build sandbox |
| =========================== |
| :class:`.Element` plugins which want to interface with the sandbox |
| need only understand this interface, while it may be given a different |
| sandbox implementation, any sandbox implementation it is given will |
| conform to this interface. |
| |
| See also: :ref:`sandboxing`. |
| """ |
| |
| import os |
| import shlex |
| import contextlib |
| from contextlib import contextmanager |
| from typing import Dict, Generator, List, Optional, TYPE_CHECKING |
| |
| from .._exceptions import ImplError, SandboxError |
| from ..storage.directory import Directory |
| from ..storage._casbaseddirectory import CasBasedDirectory |
| |
| if TYPE_CHECKING: |
| from typing import Union |
| |
| # pylint: disable=cyclic-import |
| from .._context import Context |
| from .._project import Project |
| |
| # pylint: enable=cyclic-import |
| |
| |
| class SandboxCommandError(SandboxError): |
| """Raised by :class:`.Sandbox` implementations when a command fails. |
| |
| Args: |
| message (str): The error message to report to the user |
| detail (str): The detailed error string |
| collect (str): An optional directory containing partial install contents |
| reason (str): An optional reason string (defaults to 'command-failed') |
| """ |
| |
| def __init__(self, message, *, detail=None, collect=None, reason="command-failed"): |
| super().__init__(message, detail=detail, reason=reason) |
| |
| self.collect = collect |
| |
| |
| # An internal exception which can be used to explicitly trigger a bug / exception |
| # which will be reported with a stack trace instead of reporting a user facing error |
| # |
| class _SandboxBug(Exception): |
| pass |
| |
| |
| class Sandbox: |
| """Sandbox() |
| |
| Sandbox programming interface for :class:`.Element` plugins. |
| """ |
| |
| # Minimal set of devices for the sandbox |
| _dummy_reasons = [] # type: List[str] |
| |
| def __init__(self, context: "Context", project: "Project", directory: str, **kwargs): |
| self.__context = context |
| self.__project = project |
| self.__directories = [] # type: List[str] |
| self.__cwd = None # type: Optional[str] |
| self.__env = None # type: Optional[Dict[str, str]] |
| self.__mount_sources = {} # type: Dict[str, str] |
| self.__allow_run = True |
| |
| # Plugin element full name for logging |
| plugin = kwargs.get("plugin", None) |
| if plugin: |
| self.__element_name = plugin._get_full_name() |
| else: |
| self.__element_name = None |
| |
| # Configuration from kwargs common to all subclasses |
| self.__config = kwargs["config"] |
| self.__stdout = kwargs["stdout"] |
| self.__stderr = kwargs["stderr"] |
| |
| self._vdir = None # type: Optional[Directory] |
| |
| # Pending command batch |
| self.__batch = None |
| |
| # __enter__() |
| # |
| # Called when entering the with-statement context. |
| # |
| def __enter__(self) -> "Sandbox": |
| return self |
| |
| # __exit__() |
| # |
| # Called when exiting the with-statement context. |
| # |
| def __exit__(self, exc_type, exc_value, traceback) -> None: |
| self._cleanup() |
| |
| def get_virtual_directory(self) -> Directory: |
| """Fetches the sandbox root directory as a virtual Directory. |
| |
| The root directory is where artifacts for the base |
| runtime environment should be staged. |
| |
| Returns: |
| The sandbox root directory |
| |
| """ |
| if self._vdir is None: |
| cascache = self.__context.get_cascache() |
| self._vdir = CasBasedDirectory(cascache) |
| return self._vdir |
| |
| def set_environment(self, environment: Dict[str, str]) -> None: |
| """Sets the environment variables for the sandbox |
| |
| Args: |
| environment: The environment variables to use in the sandbox |
| """ |
| self.__env = environment |
| |
| def set_work_directory(self, directory: str) -> None: |
| """Sets the work directory for commands run in the sandbox |
| |
| Args: |
| directory: An absolute path within the sandbox |
| """ |
| assert directory.startswith("/"), "The working directory must be an absolute path" |
| |
| self.__cwd = directory |
| |
| def mark_directory(self, directory: str) -> None: |
| """Marks a sandbox directory and ensures it will exist |
| |
| Args: |
| directory: An absolute path within the sandbox to mark |
| |
| .. note:: |
| Any marked directories will be read-write in the sandboxed |
| environment, only the root directory is allowed to be readonly. |
| """ |
| assert directory.startswith("/"), "The directories marked in the sandbox must be absolute paths" |
| |
| self.__directories.append(directory) |
| |
| def run( |
| self, |
| command: List[str], |
| *, |
| root_read_only: bool = False, |
| cwd: Optional[str] = None, |
| env: Optional[Dict[str, str]] = None, |
| label: str = None |
| ) -> Optional[int]: |
| """Run a command in the sandbox. |
| |
| If this is called outside a batch context, the command is immediately |
| executed. |
| |
| If this is called in a batch context, the command is added to the batch |
| for later execution. If the command fails, later commands will not be |
| executed. Command flags must match batch flags. |
| |
| Args: |
| command: The command to run in the sandboxed environment, as a list |
| of strings starting with the binary to run. |
| root_read_only: Whether the sandbox root should be readonly. |
| cwd: The sandbox relative working directory in which to run the command. |
| env: A dictionary of string key, value pairs to set as environment |
| variables inside the sandbox environment. |
| label: An optional label for the command, used for logging. |
| |
| Returns: |
| The program exit code, or None if running in batch context. |
| |
| Raises: |
| (:class:`.ProgramNotFoundError`): If a host tool which the given sandbox |
| implementation requires is not found. |
| |
| .. note:: |
| |
| The optional *cwd* argument will default to the value set with |
| :func:`~buildstream.sandbox.Sandbox.set_work_directory` and this |
| function must make sure the directory will be created if it does |
| not exist yet, even if a workspace is being used. |
| """ |
| if root_read_only: |
| flags = _SandboxFlags.ROOT_READ_ONLY |
| else: |
| flags = _SandboxFlags.NONE |
| |
| return self._run_with_flags(command, flags=flags, cwd=cwd, env=env, label=label) |
| |
| @contextmanager |
| def batch( |
| self, *, root_read_only: bool = False, label: str = None, collect: str = None |
| ) -> Generator[None, None, None]: |
| """Context manager for command batching |
| |
| This provides a batch context that defers execution of commands until |
| the end of the context. If a command fails, the batch will be aborted |
| and subsequent commands will not be executed. |
| |
| Command batches may be nested. Execution will start only when the top |
| level batch context ends. |
| |
| Args: |
| root_read_only: Whether the sandbox root should be readonly. |
| label: An optional label for the batch group, used for logging. |
| collect: An optional directory containing partial install contents |
| on command failure. |
| |
| Raises: |
| (:class:`.SandboxCommandError`): If a command fails. |
| """ |
| if root_read_only: |
| flags = _SandboxFlags.ROOT_READ_ONLY |
| else: |
| flags = _SandboxFlags.NONE |
| |
| group = _SandboxBatchGroup(label=label) |
| |
| if self.__batch: |
| # Nested batch |
| assert flags == self.__batch.flags, "Inconsistent sandbox flags in single command batch" |
| |
| parent_group = self.__batch.current_group |
| parent_group.append(group) |
| self.__batch.current_group = group |
| try: |
| yield |
| finally: |
| self.__batch.current_group = parent_group |
| else: |
| # Top-level batch |
| batch = self._create_batch(group, flags, collect=collect) |
| |
| self.__batch = batch |
| try: |
| yield |
| finally: |
| self.__batch = None |
| |
| batch.execute() |
| |
| ##################################################### |
| # Abstract Methods for Sandbox implementations # |
| ##################################################### |
| |
| # _cleanup(): |
| # |
| # Abstract method to release resources when the sandbox is discarded |
| # |
| def _cleanup(self): |
| pass |
| |
| # _run() |
| # |
| # Abstract method for running a single command |
| # |
| # Args: |
| # command (list): The command to run in the sandboxed environment, as a list |
| # of strings starting with the binary to run. |
| # flags (:class:`.SandboxFlags`): The flags for running this command. |
| # cwd (str): The sandbox relative working directory in which to run the command. |
| # env (dict): A dictionary of string key, value pairs to set as environment |
| # variables inside the sandbox environment. |
| # |
| # Returns: |
| # (int): The program exit code. |
| # |
| def _run(self, command, *, flags, cwd, env): |
| raise ImplError("Sandbox of type '{}' does not implement _run()".format(type(self).__name__)) |
| |
| # _create_batch() |
| # |
| # Abstract method for creating a batch object. Subclasses can override |
| # this method to instantiate a subclass of _SandboxBatch. |
| # |
| # Args: |
| # main_group (:class:`_SandboxBatchGroup`): The top level batch group. |
| # flags (:class:`.SandboxFlags`): The flags for commands in this batch. |
| # collect (str): An optional directory containing partial install contents |
| # on command failure. |
| # |
| def _create_batch(self, main_group, flags, *, collect=None): |
| return _SandboxBatch(self, main_group, flags, collect=collect) |
| |
| # _fetch_missing_blobs() |
| # |
| # Fetch required file blobs missing from the local cache for sandboxes using |
| # remote execution. This is a no-op for local sandboxes. |
| # |
| # Args: |
| # vdir (Directory): The virtual directory whose blobs to fetch |
| # |
| def _fetch_missing_blobs(self, vdir): |
| pass |
| |
| ################################################ |
| # Private methods # |
| ################################################ |
| |
| # _run_with_flags() |
| # |
| # An internal method for running commands, which exposes the private _SandboxFlags. |
| # |
| # Args: |
| # command: The command to run in the sandboxed environment, as a list |
| # of strings starting with the binary to run. |
| # flags: The SandboxFlags for running this command. |
| # cwd: The sandbox relative working directory in which to run the command. |
| # env: A dictionary of string key, value pairs to set as environment |
| # variables inside the sandbox environment. |
| # label: An optional label for the command, used for logging. |
| # |
| # Returns: |
| # (int): The program exit code, or None if running in batch context. |
| # |
| def _run_with_flags( |
| self, |
| command: List[str], |
| *, |
| flags: int, |
| cwd: Optional[str] = None, |
| env: Optional[Dict[str, str]] = None, |
| label: str = None |
| ) -> Optional[int]: |
| if not self.__allow_run: |
| raise _SandboxBug("Element specified BST_RUN_COMMANDS as False but called Sandbox.run()") |
| |
| # Fallback to the sandbox default settings for |
| # the cwd and env. |
| # |
| cwd = self._get_work_directory(cwd=cwd) |
| env = self._get_environment(cwd=cwd, env=env) |
| |
| assert cwd.startswith("/"), "The working directory must be an absolute path" |
| |
| # Convert single-string argument to a list |
| if isinstance(command, str): |
| command = [command] |
| |
| if self.__batch: |
| assert flags == self.__batch.flags, "Inconsistent sandbox flags in single command batch" |
| |
| batch_command = _SandboxBatchCommand(command, cwd=cwd, env=env, label=label) |
| |
| current_group = self.__batch.current_group |
| current_group.append(batch_command) |
| return None |
| else: |
| return self._run(command, flags=flags, cwd=cwd, env=env) |
| |
| # _get_context() |
| # |
| # Fetches the context BuildStream was launched with. |
| # |
| # Returns: |
| # (Context): The context of this BuildStream invocation |
| def _get_context(self): |
| return self.__context |
| |
| # _get_project() |
| # |
| # Fetches the Project this sandbox was created to build for. |
| # |
| # Returns: |
| # (Project): The project this sandbox was created for. |
| def _get_project(self): |
| return self.__project |
| |
| # _get_marked_directories() |
| # |
| # Fetches the marked directories in the sandbox |
| # |
| # Returns: |
| # (List[str]): A list of marked directories. |
| # |
| def _get_marked_directories(self): |
| return self.__directories |
| |
| # _get_mount_source() |
| # |
| # Fetches the list of mount sources |
| # |
| # Returns: |
| # (dict): A dictionary where keys are mount points and values are the mount sources |
| def _get_mount_sources(self): |
| return self.__mount_sources |
| |
| # _set_mount_source() |
| # |
| # Sets the mount source for a given mountpoint |
| # |
| # Args: |
| # mountpoint (str): The absolute mountpoint path inside the sandbox |
| # mount_source (str): the host path to be mounted at the mount point |
| def _set_mount_source(self, mountpoint, mount_source): |
| self.__mount_sources[mountpoint] = mount_source |
| |
| # _get_environment() |
| # |
| # Fetches the environment variables for running commands |
| # in the sandbox. |
| # |
| # Args: |
| # cwd (str): The working directory the command has been requested to run in, if any. |
| # env (str): The environment the command has been requested to run in, if any. |
| # |
| # Returns: |
| # (str): The sandbox work directory |
| def _get_environment(self, *, cwd=None, env=None): |
| cwd = self._get_work_directory(cwd=cwd) |
| if env is None: |
| env = self.__env |
| |
| # Naive getcwd implementations can break when bind-mounts to different |
| # paths on the same filesystem are present. Letting the command know |
| # what directory it is in makes it unnecessary to call the faulty |
| # getcwd. |
| env = dict(env) |
| env["PWD"] = cwd |
| |
| return env |
| |
| # _get_work_directory() |
| # |
| # Fetches the working directory for running commands |
| # in the sandbox. |
| # |
| # Args: |
| # cwd (str): The working directory the command has been requested to run in, if any. |
| # |
| # Returns: |
| # (str): The sandbox work directory |
| def _get_work_directory(self, *, cwd=None) -> str: |
| return cwd or self.__cwd or "/" |
| |
| # _get_output() |
| # |
| # Fetches the stdout & stderr |
| # |
| # Returns: |
| # (file): The stdout, or None to inherit |
| # (file): The stderr, or None to inherit |
| def _get_output(self): |
| return (self.__stdout, self.__stderr) |
| |
| # _get_config() |
| # |
| # Fetches the sandbox configuration object. |
| # |
| # Returns: |
| # (SandboxConfig): An object containing the configuration |
| # data passed in during construction. |
| def _get_config(self): |
| return self.__config |
| |
| # _has_command() |
| # |
| # Tests whether a command exists inside the sandbox |
| # |
| # Args: |
| # command (list): The command to test. |
| # env (dict): A dictionary of string key, value pairs to set as environment |
| # variables inside the sandbox environment. |
| # Returns: |
| # (bool): Whether a command exists inside the sandbox. |
| def _has_command(self, command, env=None): |
| vroot = self.get_virtual_directory() |
| if os.path.isabs(command): |
| return vroot.exists(command.lstrip(os.sep), follow_symlinks=True) |
| |
| if "/" in command: |
| return False |
| |
| for path in env.get("PATH").split(":"): |
| try_path = os.path.join(path, command).lstrip(os.sep) |
| if vroot.exists(try_path, follow_symlinks=True): |
| return True |
| |
| return False |
| |
| # _create_empty_file() |
| # |
| # Creates an empty file in the current working directory. |
| # |
| # If this is called outside a batch context, the file is created |
| # immediately. |
| # |
| # If this is called in a batch context, creating the file is deferred. |
| # |
| # Args: |
| # path (str): The path of the file to be created |
| # |
| def _create_empty_file(self, name): |
| if self.__batch: |
| batch_file = _SandboxBatchFile(name) |
| |
| current_group = self.__batch.current_group |
| current_group.append(batch_file) |
| else: |
| vdir = self.get_virtual_directory() |
| cwd = self._get_work_directory() |
| cwd_vdir = vdir.open_directory(cwd.lstrip(os.sep), create=True) |
| cwd_vdir._create_empty_file(name) |
| |
| # _clean_directory() |
| # |
| # Remove the contents of the specified directory. |
| # |
| # Args: |
| # path (str): The path of the directory to be cleaned |
| # |
| def _clean_directory(self, path): |
| if self.__batch: |
| batch_clean = _SandboxBatchCleanDirectory(path) |
| |
| current_group = self.__batch.current_group |
| current_group.append(batch_clean) |
| else: |
| vdir = self.get_virtual_directory() |
| relative_path = path.lstrip(os.sep) |
| if vdir.exists(relative_path): |
| vdir.remove(relative_path, recursive=True) |
| vdir.open_directory(relative_path, create=True) |
| |
| # _get_element_name() |
| # |
| # Get the plugin's element full name |
| # |
| def _get_element_name(self): |
| return self.__element_name |
| |
| # _disable_run() |
| # |
| # Raise exception if `Sandbox.run()` is called. |
| # |
| # This enforces an invariant by raising an exception if an element |
| # plugin ever set BST_RUN_COMMANDS to False but then proceeded to |
| # attempt to run the sandbox at assemble time. |
| # |
| def _disable_run(self): |
| self.__allow_run = False |
| |
| |
| # SandboxFlags() |
| # |
| # Flags indicating how the sandbox should be run. |
| # |
| class _SandboxFlags: |
| |
| # Use default sandbox configuration. |
| # |
| NONE = 0 |
| |
| # Whether the root filesystem should be readonly. |
| # |
| # Usually this is true except for when running integration commands |
| ROOT_READ_ONLY = 0x01 |
| |
| # Whether to expose host network. |
| # |
| # This should not be set when running builds, but can |
| # be allowed for running a shell in a sandbox. |
| NETWORK_ENABLED = 0x02 |
| |
| # Whether to run the sandbox interactively. |
| # |
| # This determines if the sandbox should attempt to connect |
| # the terminal through to the calling process, or detach |
| # the terminal entirely. |
| INTERACTIVE = 0x04 |
| |
| # Whether to use the user id and group id from the host environment. |
| # |
| # This determines if processes in the sandbox should run with the |
| # same user id and group id as BuildStream itself. By default, |
| # processes run with user id and group id 0, protected by a user |
| # namespace where available. |
| INHERIT_UID = 0x08 |
| |
| |
| # _SandboxBatch() |
| # |
| # A batch of sandbox commands. |
| # |
| class _SandboxBatch: |
| def __init__(self, sandbox, main_group, flags, *, collect=None): |
| self.sandbox = sandbox |
| self.main_group = main_group |
| self.current_group = main_group |
| self.flags = flags |
| self.collect = collect |
| |
| def execute(self): |
| self.main_group.execute(self) |
| |
| def execute_group(self, group): |
| if group.label: |
| context = self.sandbox._get_context() |
| cm = context.messenger.timed_activity(group.label, element_name=self.sandbox._get_element_name()) |
| else: |
| cm = contextlib.suppress() |
| |
| with cm: |
| group.execute_children(self) |
| |
| def execute_command(self, command): |
| if command.label: |
| context = self.sandbox._get_context() |
| context.messenger.status( |
| "Running command", |
| detail=command.label, |
| element_name=self.sandbox._get_element_name(), |
| ) |
| |
| exitcode = self.sandbox._run(command.command, flags=self.flags, cwd=command.cwd, env=command.env) |
| if exitcode != 0: |
| cmdline = " ".join(shlex.quote(cmd) for cmd in command.command) |
| label = command.label or cmdline |
| raise SandboxCommandError( |
| "Command failed with exitcode {}".format(exitcode), detail=label, collect=self.collect |
| ) |
| |
| def create_empty_file(self, name): |
| vdir = self.sandbox.get_virtual_directory() |
| cwd = self.sandbox._get_work_directory() |
| cwd_vdir = vdir.open_directory(cwd.lstrip(os.sep), create=True) |
| cwd_vdir._create_empty_file(name) |
| |
| def clean_directory(self, name): |
| vdir = self.sandbox.get_virtual_directory() |
| relative_path = name.lstrip(os.sep) |
| if vdir.exists(relative_path): |
| vdir.remove(relative_path, recursive=True) |
| vdir.open_directory(relative_path, create=True) |
| |
| |
| # _SandboxBatchItem() |
| # |
| # An item in a command batch. |
| # |
| class _SandboxBatchItem: |
| def __init__(self, *, label=None): |
| self.label = label |
| |
| def combined_label(self): |
| return self.label |
| |
| |
| # _SandboxBatchCommand() |
| # |
| # A command item in a command batch. |
| # |
| class _SandboxBatchCommand(_SandboxBatchItem): |
| def __init__(self, command, *, cwd, env, label=None): |
| super().__init__(label=label) |
| |
| self.command = command |
| self.cwd = cwd |
| self.env = env |
| |
| def execute(self, batch): |
| batch.execute_command(self) |
| |
| |
| # _SandboxBatchGroup() |
| # |
| # A group in a command batch. |
| # |
| class _SandboxBatchGroup(_SandboxBatchItem): |
| def __init__(self, *, label=None): |
| super().__init__(label=label) |
| |
| self.children = [] |
| |
| def append(self, item): |
| self.children.append(item) |
| |
| def execute(self, batch): |
| batch.execute_group(self) |
| |
| def execute_children(self, batch): |
| for item in self.children: |
| item.execute(batch) |
| |
| def combined_label(self): |
| return "\n".join(filter(None, (item.combined_label() for item in self.children))) |
| |
| |
| # _SandboxBatchFile() |
| # |
| # A file creation item in a command batch. |
| # |
| class _SandboxBatchFile(_SandboxBatchItem): |
| def __init__(self, name): |
| super().__init__() |
| |
| self.name = name |
| |
| def execute(self, batch): |
| batch.create_empty_file(self.name) |
| |
| |
| # _SandboxBatchCleanDirectory() |
| # |
| # A directory cleaning item in a command batch. |
| # |
| class _SandboxBatchCleanDirectory(_SandboxBatchItem): |
| def __init__(self, name): |
| super().__init__() |
| |
| self.name = name |
| |
| def execute(self, batch): |
| batch.clean_directory(self.name) |