blob: 1987ebac16c123231d0a7fdad2e7a3e3a4fd380d [file] [log] [blame]
#
# 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.
import os
import signal
import subprocess
import sys
from contextlib import ExitStack
import psutil
from .. import utils, _signals
from . import _SandboxFlags
from .._exceptions import SandboxError
from .._platform import Platform
from .._protos.build.bazel.remote.execution.v2 import remote_execution_pb2
from ._sandboxreapi import SandboxREAPI
# SandboxBuildBoxRun()
#
# BuildBox-based sandbox implementation.
#
class SandboxBuildBoxRun(SandboxREAPI):
@classmethod
def __buildbox_run(cls):
return utils._get_host_tool_internal("buildbox-run", search_subprojects_dir="buildbox")
@classmethod
def check_available(cls):
try:
path = cls.__buildbox_run()
except utils.ProgramNotFoundError as Error:
cls._dummy_reasons += ["buildbox-run not found"]
raise SandboxError(" and ".join(cls._dummy_reasons), reason="unavailable-local-sandbox") from Error
exit_code, output = utils._call([path, "--capabilities"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if exit_code == 0:
# buildbox-run --capabilities prints one capability per line
cls._capabilities = set(output.split("\n"))
elif "Invalid option --capabilities" in output:
# buildbox-run is too old to support extra capabilities
cls._capabilities = set()
else:
# buildbox-run is not functional
cls._dummy_reasons += ["buildbox-run: {}".format(output)]
raise SandboxError(" and ".join(cls._dummy_reasons), reason="unavailable-local-sandbox")
osfamily_prefix = "platform:OSFamily="
cls._osfamilies = {cap[len(osfamily_prefix) :] for cap in cls._capabilities if cap.startswith(osfamily_prefix)}
if not cls._osfamilies:
# buildbox-run is too old to list supported OS families,
# limit support to native building on the host OS.
cls._osfamilies.add(Platform.get_host_os())
isa_prefix = "platform:ISA="
cls._isas = {cap[len(isa_prefix) :] for cap in cls._capabilities if cap.startswith(isa_prefix)}
if not cls._isas:
# buildbox-run is too old to list supported ISAs,
# limit support to native building on the host ISA.
cls._isas.add(Platform.get_host_arch())
@classmethod
def check_sandbox_config(cls, config):
if config.build_os not in cls._osfamilies:
raise SandboxError("OS '{}' is not supported by buildbox-run.".format(config.build_os))
if config.build_arch not in cls._isas:
raise SandboxError("ISA '{}' is not supported by buildbox-run.".format(config.build_arch))
if config.build_uid is not None and "platform:unixUID" not in cls._capabilities:
raise SandboxError("Configuring sandbox UID is not supported by buildbox-run.")
if config.build_gid is not None and "platform:unixGID" not in cls._capabilities:
raise SandboxError("Configuring sandbox GID is not supported by buildbox-run.")
def _execute_action(self, action, flags):
stdout, stderr = self._get_output()
context = self._get_context()
cascache = context.get_cascache()
casd_process_manager = cascache.get_casd_process_manager()
with utils._tempnamedfile() as action_file, utils._tempnamedfile() as result_file:
action_file.write(action.SerializeToString())
action_file.flush()
buildbox_command = [
self.__buildbox_run(),
"--remote={}".format(casd_process_manager._connection_string),
"--action={}".format(action_file.name),
"--action-result={}".format(result_file.name),
]
# Do not redirect stdout/stderr
if "no-logs-capture" in self._capabilities:
buildbox_command.append("--no-logs-capture")
marked_directories = self._get_marked_directories()
mount_sources = self._get_mount_sources()
for mount_point in marked_directories:
mount_source = mount_sources.get(mount_point)
if not mount_source:
# Handled by the input tree in the action
continue
if "bind-mount" not in self._capabilities:
context = self._get_context()
context.messenger.warn("buildbox-run does not support host-files")
break
buildbox_command.append("--bind-mount={}:{}".format(mount_source, mount_point))
# 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
if "bind-mount" in self._capabilities:
# In interactive mode, we want a complete devpts inside
# the container, so there is a /dev/console and such.
buildbox_command.append("--bind-mount=/dev:/dev")
else:
stdin = subprocess.DEVNULL
self._run_buildbox(
buildbox_command,
stdin,
stdout,
stderr,
interactive=(flags & _SandboxFlags.INTERACTIVE),
)
return remote_execution_pb2.ActionResult().FromString(result_file.read())
def _run_buildbox(self, argv, stdin, stdout, stderr, *, interactive):
def kill_proc():
if process:
# First attempt to gracefully terminate
proc = psutil.Process(process.pid)
proc.terminate()
try:
proc.wait(15)
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 ExitStack() as stack:
# We want to launch buildbox-run in a new session in non-interactive
# mode so that we handle the SIGTERM and SIGTSTP signals separately
# from the nested process, but in interactive mode this causes
# launched shells to lack job control as the signals don't reach
# the shell process.
#
if interactive:
new_session = False
else:
new_session = True
stack.enter_context(_signals.suspendable(suspend_proc, resume_proc))
stack.enter_context(_signals.terminator(kill_proc))
process = subprocess.Popen( # pylint: disable=consider-using-with
argv,
close_fds=True,
stdin=stdin,
stdout=stdout,
stderr=stderr,
start_new_session=new_session,
)
# 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:
# Here, we don't use `process.wait()` directly without a timeout
# This is because, if we were to do that, and the process would never
# output anything, the control would never be given back to the python
# process, which might thus not be able to check for request to
# shutdown, or kill the process.
# We therefore loop with a timeout, to ensure the python process
# can act if it needs.
returncode = process.wait(timeout=1)
# If the process exits due to a signal, we
# brutally murder it to avoid zombies
if returncode < 0:
utils._kill_process_tree(process.pid)
except subprocess.TimeoutExpired:
continue
# 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 _signals.TerminateException:
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
if returncode != 0:
raise SandboxError("buildbox-run failed with returncode {}".format(returncode))
def _supported_platform_properties(self):
return {"OSFamily", "ISA", "unixUID", "unixGID", "network"}