blob: 4258ee26d6a598f73bf9647be73633b890871a3e [file] [log] [blame]
#
# Copyright (C) 2018 Bloomberg LP
#
# 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/>.
import os
import sys
import signal
import subprocess
from contextlib import ExitStack
import psutil
from .. import utils, _signals, ProgramNotFoundError
from . import Sandbox, SandboxFlags, SandboxCommandError
from .._protos.build.bazel.remote.execution.v2 import remote_execution_pb2
from ..storage._casbaseddirectory import CasBasedDirectory
from .._exceptions import SandboxError
# SandboxBuidBox()
#
# BuildBox-based sandbox implementation.
#
class SandboxBuildBox(Sandbox):
def __init__(self, context, project, directory, **kwargs):
if kwargs.get('allow_real_directory'):
raise SandboxError("BuildBox does not support real directories")
kwargs['allow_real_directory'] = False
super().__init__(context, project, directory, **kwargs)
@classmethod
def check_available(cls):
try:
utils.get_host_tool('buildbox')
except utils.ProgramNotFoundError as Error:
cls._dummy_reasons += ["buildbox not found"]
raise SandboxError(" and ".join(cls._dummy_reasons),
reason="unavailable-local-sandbox") from Error
@classmethod
def check_sandbox_config(cls, platform, config):
# Report error for elements requiring non-0 UID/GID
# TODO
if config.build_uid != 0 or config.build_gid != 0:
return False
# Check host os and architecture match
if config.build_os != platform.get_host_os():
raise SandboxError("Configured and host OS don't match.")
if config.build_arch != platform.get_host_arch():
raise SandboxError("Configured and host architecture don't match.")
return True
def _run(self, command, flags, *, cwd, env):
stdout, stderr = self._get_output()
root_directory = self.get_virtual_directory()
scratch_directory = self._get_scratch_directory()
if not self._has_command(command[0], env):
raise SandboxCommandError("Staged artifacts do not provide command "
"'{}'".format(command[0]),
reason='missing-command')
# Grab the full path of the buildbox binary
try:
buildbox_command = [utils.get_host_tool('buildbox')]
except ProgramNotFoundError as Err:
raise SandboxError(("BuildBox not on path, you are using the BuildBox sandbox because "
"BST_FORCE_SANDBOX=buildbox")) from Err
for mark in self._get_marked_directories():
path = mark['directory']
assert path.startswith('/') and len(path) > 1
root_directory.descend(*path[1:].split(os.path.sep), create=True)
digest = root_directory._get_digest()
with open(os.path.join(scratch_directory, 'in'), 'wb') as input_digest_file:
input_digest_file.write(digest.SerializeToString())
buildbox_command += ["--local=" + root_directory.cas_cache.casdir]
buildbox_command += ["--input-digest=in"]
buildbox_command += ["--output-digest=out"]
common_details = ("BuildBox is a experimental sandbox and does not support the requested feature.\n"
"You are using this feature because BST_FORCE_SANDBOX=buildbox.")
if not flags & SandboxFlags.NETWORK_ENABLED:
# TODO
self._issue_warning(
"BuildBox sandbox does not have Networking yet",
detail=common_details
)
if cwd is not None:
buildbox_command += ['--chdir=' + cwd]
# In interactive mode, we want a complete devpts inside
# the container, so there is a /dev/console and such. In
# the regular non-interactive sandbox, we want to hand pick
# a minimal set of devices to expose to the sandbox.
#
if flags & SandboxFlags.INTERACTIVE:
# TODO
self._issue_warning(
"BuildBox sandbox does not fully support BuildStream shells yet",
detail=common_details
)
if flags & SandboxFlags.ROOT_READ_ONLY:
# TODO
self._issue_warning(
"BuildBox sandbox does not fully support BuildStream `Read only Root`",
detail=common_details
)
# Set UID and GID
if not flags & SandboxFlags.INHERIT_UID:
# TODO
self._issue_warning(
"BuildBox sandbox does not fully support BuildStream Inherit UID",
detail=common_details
)
os.makedirs(os.path.join(scratch_directory, 'mnt'), exist_ok=True)
buildbox_command += ['mnt']
# Add the command
buildbox_command += command
# Use the MountMap context manager to ensure that any redirected
# mounts through fuse layers are in context and ready for buildbox
# to mount them from.
#
with ExitStack() as stack:
# Ensure the cwd exists
if cwd is not None and len(cwd) > 1:
assert cwd.startswith('/')
root_directory.descend(*cwd[1:].split(os.path.sep), create=True)
# If we're interactive, we want to inherit our stdin,
# otherwise redirect to /dev/null, ensuring process
# disconnected from terminal.
if flags & SandboxFlags.INTERACTIVE:
stdin = sys.stdin
else:
stdin = stack.enter_context(open(os.devnull, "r"))
# Run buildbox !
exit_code = self.run_buildbox(buildbox_command, stdin, stdout, stderr, env,
interactive=(flags & SandboxFlags.INTERACTIVE),
cwd=scratch_directory)
if exit_code == 0:
with open(os.path.join(scratch_directory, 'out'), 'rb') as output_digest_file:
output_digest = remote_execution_pb2.Digest()
output_digest.ParseFromString(output_digest_file.read())
self._vdir = CasBasedDirectory(root_directory.cas_cache, digest=output_digest)
return exit_code
def run_buildbox(self, argv, stdin, stdout, stderr, env, *, interactive, cwd):
def kill_proc():
if process:
# First attempt to gracefully terminate
proc = psutil.Process(process.pid)
proc.terminate()
try:
proc.wait(20)
except psutil.TimeoutExpired:
utils._kill_process_tree(process.pid)
def suspend_proc():
group_id = os.getpgid(process.pid)
os.killpg(group_id, signal.SIGSTOP)
def resume_proc():
group_id = os.getpgid(process.pid)
os.killpg(group_id, signal.SIGCONT)
with _signals.suspendable(suspend_proc, resume_proc), _signals.terminator(kill_proc):
process = subprocess.Popen(
argv,
close_fds=True,
env=env,
stdin=stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
start_new_session=interactive
)
# Wait for the child process to finish, ensuring that
# a SIGINT has exactly the effect the user probably
# expects (i.e. let the child process handle it).
try:
while True:
try:
_, status = os.waitpid(process.pid, 0)
# If the process exits due to a signal, we
# brutally murder it to avoid zombies
if not os.WIFEXITED(status):
utils._kill_process_tree(process.pid)
# Unlike in the bwrap case, here only the main
# process seems to receive the SIGINT. We pass
# on the signal to the child and then continue
# to wait.
except KeyboardInterrupt:
process.send_signal(signal.SIGINT)
continue
break
# If we can't find the process, it has already died of
# its own accord, and therefore we don't need to check
# or kill anything.
except psutil.NoSuchProcess:
pass
# Return the exit code - see the documentation for
# os.WEXITSTATUS to see why this is required.
if os.WIFEXITED(status):
exit_code = os.WEXITSTATUS(status)
else:
exit_code = -1
return exit_code
def _use_cas_based_directory(self):
# Always use CasBasedDirectory for BuildBox
return True