summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTristan Maat <tristan.maat@codethink.com>2017-07-18 16:09:22 +0100
committerTristan Maat <tristan.maat@codethink.co.uk>2017-09-28 11:30:50 +0100
commita023e2090fc0abdbaf3f6e29071fbfc3d5d81ca6 (patch)
treed7250b08535b1c540b4f201ed6a54807c846a9b8
parent7d9e974ecf3494e58d48cd0c29d5fbfe148010c0 (diff)
downloadbuildstream-a023e2090fc0abdbaf3f6e29071fbfc3d5d81ca6.tar.gz
_sandboxchroot.py: Implement sandboxchroot
-rw-r--r--buildstream/_sandboxchroot.py450
1 files changed, 450 insertions, 0 deletions
diff --git a/buildstream/_sandboxchroot.py b/buildstream/_sandboxchroot.py
new file mode 100644
index 000000000..03bdecff4
--- /dev/null
+++ b/buildstream/_sandboxchroot.py
@@ -0,0 +1,450 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2017 Codethink Limited
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+#
+# Authors:
+# Tristan Maat <tristan.maat@codethink.co.uk>
+# Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
+
+import os
+import sys
+import stat
+import shutil
+import subprocess
+from contextlib import contextmanager, ExitStack
+
+from . import utils
+from . import ElementError
+from . import Sandbox, SandboxFlags
+
+
+# A class to wrap the `mount` and `umount` commands
+class Mount():
+ def __init__(self, platform):
+ self.platform = platform
+
+ def _mount(self, dest, src=None, mount_type=None,
+ stdout=sys.stdout, stderr=sys.stderr, options=None,
+ flags=None):
+
+ argv = [utils.get_host_tool('mount')]
+ if mount_type:
+ argv.extend(['-t', mount_type])
+ if options:
+ argv.extend(['-o', options])
+ if flags:
+ argv.extend(flags)
+
+ if src is not None:
+ argv += [src]
+ argv += [dest]
+
+ status, _ = utils._call(
+ argv,
+ terminate=True,
+ stdout=stdout,
+ stderr=stderr
+ )
+
+ if status != 0:
+ raise ElementError('`{}` failed with exit code {}'
+ .format(' '.join(argv), status))
+
+ return dest
+
+ def _umount(self, path, stdout=sys.stdout, stderr=sys.stderr):
+
+ cmd = [utils.get_host_tool('umount'), '-R', path]
+ status, _ = utils._call(
+ cmd,
+ terminate=True,
+ stdout=stdout,
+ stderr=stderr
+ )
+
+ if status != 0:
+ raise ElementError('`{}` failed with exit code {}'
+ .format(' '.join(cmd), status))
+
+ # mount()
+ #
+ # A wrapper for the `mount` command. The device is unmounted when
+ # the context is left.
+ #
+ # Args:
+ # src (str) - The directory to mount
+ # dest (str) - The directory to mount to
+ # mount_type (str|None) - The mount type (can be omitted or None)
+ # kwargs - Arguments to pass to the mount command, such as `ro=True`
+ #
+ # Yields:
+ # (str) The path to the destination
+ #
+ @contextmanager
+ def mount(self, dest, src=None, mount_type=None,
+ stdout=sys.stdout, stderr=sys.stderr, **kwargs):
+
+ options = ','.join([key for key, val in kwargs.items() if val])
+
+ yield self._mount(dest, src, mount_type, stdout=stdout, stderr=stderr, options=options)
+
+ self._umount(dest, stdout, stderr)
+
+ # bind_mount()
+ #
+ # Mount a directory to a different location (a hardlink for all
+ # intents and purposes). The directory is unmounted when the
+ # context is left.
+ #
+ # Args:
+ # src (str) - The directory to mount
+ # dest (str) - The directory to mount to
+ # kwargs - Arguments to pass to the mount command, such as `ro=True`
+ #
+ # Yields:
+ # (str) The path to the destination
+ #
+ # While this is equivalent to `mount --rbind`, this option may not
+ # exist and can be dangerous, requiring careful cleanupIt is
+ # recommended to use this function over a manual mount invocation.
+ #
+ @contextmanager
+ def bind_mount(self, dest, src=None, stdout=sys.stdout, stderr=sys.stderr,
+ **kwargs):
+
+ kwargs['rbind'] = True
+ options = ','.join([key for key, val in kwargs.items() if val])
+
+ path = self._mount(dest, src, None, stdout, stderr, options)
+
+ # Make the rbind a slave to avoid unmounting vital devices in
+ # /proc
+ self._mount(dest, flags=['--make-rslave'])
+
+ yield path
+
+ self._umount(dest, stdout, stderr)
+
+
+class SandboxChroot(Sandbox):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.platform = self._get_context()._platform
+ self.mount = Mount(self.platform)
+
+ def run(self, command, flags, cwd=None, env=None):
+
+ # Default settings
+ if cwd is None:
+ cwd = self._get_work_directory()
+
+ if cwd is None:
+ cwd = '/'
+
+ if env is None:
+ env = self._get_environment()
+
+ # Command must be a list
+ if isinstance(command, str):
+ command = [command]
+
+ stdout, stderr = self._get_output()
+
+ # Create a chroot directory and run the command inside it
+ with ExitStack() as stack:
+ if flags & SandboxFlags.INTERACTIVE:
+ stdin = sys.stdin
+ status = self.run_in_interactive_sandbox(command, env, flags, cwd)
+ else:
+ stdin = stack.enter_context(open(os.devnull, 'r'))
+ status = self.run_in_sandbox(command, stdin, stdout,
+ stderr, cwd, env, flags)
+
+ return status
+
+ # run_in_sandbox()
+ #
+ # A helper function to pass the command to the chroot.
+ #
+ # Args:
+ # command (list): The command to execute in the chroot
+ # stdin (file): The stdin
+ # stdout (file): The stdout
+ # stderr (file): The stderr
+ # interactive (bool): Whether the sandbox should be run interactively
+ # cwd (str): The current working directory
+ # env (dict): The environment variables to use while executing the command
+ #
+ # Returns:
+ # (int): The exit code of the executed command
+ #
+ def run_in_sandbox(self, command, stdin, stdout, stderr, cwd, env, flags):
+ # Hack to ensure a module required for exception handling is
+ # not loaded in the chroot.
+ #
+ # Propagates from here:
+ # https://github.com/CodethinkLabs/sandboxlib/blob/7e2a551189b5ffb7a0124db63964bdec69ead3e8/sandboxlib/chroot.py#L231
+ _ = "Some Text".encode('unicode-escape')
+
+ with ExitStack() as stack:
+ stack.enter_context(self.create_devices(flags))
+ stack.enter_context(self.mount_dirs(flags, stdout, stderr))
+
+ try:
+ code, _ = utils._call(
+ command,
+ terminate=True,
+ close_fds=True,
+ cwd=os.path.join(self.get_directory(), cwd.lstrip(os.sep)),
+ env=env,
+ stdin=stdin,
+ stdout=stdout,
+ stderr=stderr,
+ # If you try to put gtk dialogs here Tristan (either)
+ # will personally scald you
+ preexec_fn=lambda: (os.chroot(self.get_directory()), os.chdir(cwd)),
+ start_new_session=flags & SandboxFlags.INTERACTIVE
+ )
+ except subprocess.SubprocessError as e:
+ # Exceptions in preexec_fn are simply reported as
+ # 'Exception occurred in preexec_fn', turn these into
+ # a more readable message.
+ if '{}'.format(e) == 'Exception occurred in preexec_fn.':
+ raise ElementError('Could not chroot into {} or chdir into {}. '
+ 'Ensure you are root and that the relevant directory exists.'
+ .format(self.get_directory(), cwd)) from e
+ else:
+ raise ElementError('Could not run command {}: {}'.format(command, e)) from e
+
+ if code != 0:
+ raise ElementError("{} failed with exit code {}".format(command, code))
+
+ return code
+
+ # run_in_interactive_sandbox()
+ #
+ # Run an interactive command.
+ #
+ #
+ # Args:
+ # command (list): The command to execute in the chroot
+ # stdin (file): The stdin
+ # stdout (file): The stdout
+ # stderr (file): The stderr
+ # interactive (bool): Whether the sandbox should be run interactively
+ # cwd (str): The current working directory
+ # env (dict): The environment variables to use while executing the command
+ #
+ # Returns:
+ # (int): The exit code of the executed command
+ #
+ # The method is similar to SandboxChroot.run_in_sandbox(), but
+ # does not create a subprocess to allow the interactive session to
+ # communicate with the frontend. It foregoes changing into `cwd`
+ # since this is less important in an interactive session.
+ #
+ def run_in_interactive_sandbox(self, command, env, flags, cwd):
+
+ with ExitStack() as stack:
+ stack.enter_context(self.create_devices(flags))
+ stack.enter_context(self.mount_dirs(flags, sys.stdout, sys.stderr))
+
+ process = subprocess.Popen(
+ command,
+ cwd=os.path.join(self.get_directory(), cwd.lstrip(os.sep)),
+ close_fds=True,
+ preexec_fn=lambda: (os.chroot(self.get_directory()), os.chdir(cwd)),
+ env=env
+ )
+ process.communicate()
+
+ return process.poll()
+
+ # create_devices()
+ #
+ # Create the nodes in /dev/ usually required for builds (null,
+ # none, etc.)
+ #
+ # Args:
+ # flags (:class:`.SandboxFlags`): The sandbox flags
+ #
+ @contextmanager
+ def create_devices(self, flags):
+
+ devices = []
+ # When we are interactive, we'd rather mount /dev due to the
+ # sheer number of devices
+ if not flags & SandboxFlags.INTERACTIVE:
+ os.makedirs('/dev/', exist_ok=True)
+
+ for device in Sandbox.DEVICES:
+ location = os.path.join(self.get_directory(), device.lstrip(os.sep))
+ os.makedirs(os.path.dirname(location), exist_ok=True)
+ try:
+ # If the image already contains a device, remove
+ # it, since the device numbers may be different on
+ # different systems.
+ os.remove(location)
+
+ devices.append(self.mknod(device, location))
+ except OSError as err:
+ if err.errno == 1:
+ raise ElementError("Permission denied while creating device node: {}.".format(err) +
+ "BuildStream reqiures root permissions for these setttings.")
+
+ yield
+
+ for device in devices:
+ os.remove(device)
+
+ # mount()
+ #
+ # Mount paths required for the command.
+ #
+ # Args:
+ # flags (:class:`.SandboxFlags`): The sandbox flags
+ #
+ @contextmanager
+ def mount_dirs(self, flags, stdout, stderr):
+
+ # FIXME: This should probably keep track of potentially
+ # already existing files a la _sandboxwrap.py:239
+
+ # To successfully mount the sysroot RO, we need to:
+ # - Mount / RO first since solaris can't remount a bind mount
+ # - Create missing directories and mount points
+ # - Move marked directories to a scratch directory to keep their RW state
+ # - Mount / RO
+ # - Mount marked directories RW
+ # - Mount system directories (/dev, /proc/, /tmp, ...)
+ #
+ # - execute command
+ #
+ # - Unmount system and marked directories
+ # - Unmount /
+
+ # Create missing system directories
+ if flags & SandboxFlags.INTERACTIVE:
+ # We mount /dev in interactive sandboxes to give the
+ # user a working console
+ dev_src, dev_point = self.get_mount_location('/dev/', '/dev/')
+
+ proc_src, proc_point = self.get_mount_location('/proc/', '/proc/')
+ tmp_src, tmp_point = self.get_mount_location('/tmp', '/tmp')
+
+ marked_directories = self._get_marked_directories()
+ marked_locations = []
+
+ for mark in marked_directories:
+ host_location = os.path.join(self.get_directory(),
+ mark['directory'].lstrip(os.sep))
+ scratch_location = os.path.join(self._get_scratch_directory(),
+ mark['directory'].lstrip(os.sep))
+
+ # On the first invocation, move the marked directories to
+ # the scratch directory to allow mounting them read-write
+
+ # Create the host location if it does not exist yet
+ os.makedirs(host_location, exist_ok=True)
+
+ # Move marked directories before mounting / RO
+ shutil.move(host_location, scratch_location)
+
+ # Record the mount locations
+ marked_locations.append(self.get_mount_location(scratch_location, mark['directory']))
+
+ with ExitStack() as stack:
+ # / mount
+ root_mount = self.mount.bind_mount(self.get_directory(), self.get_directory(),
+ stdout, stderr,
+ ro=flags & SandboxFlags.ROOT_READ_ONLY)
+ # Since / needs to be unmounted after and before
+ # everything else has been unmounted, manually enter/exit
+ # the context.
+ root_mount.__enter__()
+
+ # System mounts
+ if flags & SandboxFlags.INTERACTIVE:
+ stack.enter_context(self.mount.bind_mount(dev_point, dev_src, stdout, stderr))
+
+ stack.enter_context(self.mount.mount(proc_point, proc_src, 'proc', stdout, stderr, ro=True))
+ stack.enter_context(self.mount.bind_mount(tmp_point, tmp_src, stdout, stderr))
+
+ # Mark mounts
+ for src, point in marked_locations:
+ stack.enter_context(self.mount.bind_mount(point, src, stdout, stderr))
+
+ yield
+
+ root_mount.__exit__(None, None, None)
+
+ # Move back mark mounts
+ for src, point in marked_locations:
+ shutil.rmtree(point)
+ shutil.move(src, point)
+
+ # get_mount_location()
+ #
+ # Return a tuple that indicates the locations to mount a host
+ # directory to and from to mount it to the corresponding path in
+ # the chroot.
+ #
+ # Args:
+ # host_dir (str): The path to the host dir to mount.
+ # chroot_dir (str): The dir in the chroot to mount it to.
+ #
+ # Returns:
+ # (str, str) - (source, point)
+ #
+ def get_mount_location(self, host_dir, chroot_dir):
+ point = os.path.join(self.get_directory(), chroot_dir.lstrip(os.sep))
+
+ os.makedirs(host_dir, exist_ok=True)
+ os.makedirs(point, exist_ok=True)
+
+ return (host_dir, point)
+
+ # mknod()
+ #
+ # Create a device node equivalent to the given source node
+ #
+ # Args:
+ # source (str): Path of the device to mimic (e.g. '/dev/null')
+ # target (str): Location to create the new device in
+ #
+ # Returns:
+ # target (str): The location of the created node
+ #
+ def mknod(self, source, target):
+ try:
+ dev = os.stat(source)
+
+ major = os.major(dev.st_rdev)
+ minor = os.minor(dev.st_rdev)
+
+ target_dev = os.makedev(major, minor)
+
+ os.mknod(target, mode=stat.S_IFCHR | dev.st_mode, device=target_dev)
+
+ except PermissionError as e:
+ raise ElementError('Could not create device {}, ensure that you have root permissions: {}')
+
+ except OSError as e:
+ raise ElementError('Could not create device {}: {}'
+ .format(target, e)) from e
+
+ return target