| // Copyright 2018 The Bazel Authors. All rights reserved. |
| // |
| // 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. |
| |
| package com.google.devtools.build.lib.sandbox; |
| |
| import build.bazel.remote.execution.v2.Platform; |
| import com.google.common.base.Throwables; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.MoreCollectors; |
| import com.google.common.eventbus.Subscribe; |
| import com.google.common.io.ByteStreams; |
| import com.google.devtools.build.lib.actions.ExecException; |
| import com.google.devtools.build.lib.actions.ForbiddenActionInputException; |
| import com.google.devtools.build.lib.actions.Spawn; |
| import com.google.devtools.build.lib.actions.Spawns; |
| import com.google.devtools.build.lib.actions.UserExecException; |
| import com.google.devtools.build.lib.analysis.platform.PlatformUtils; |
| import com.google.devtools.build.lib.events.Event; |
| import com.google.devtools.build.lib.events.Reporter; |
| import com.google.devtools.build.lib.exec.TreeDeleter; |
| import com.google.devtools.build.lib.exec.local.LocalEnvProvider; |
| import com.google.devtools.build.lib.remote.options.RemoteOptions; |
| import com.google.devtools.build.lib.runtime.CommandCompleteEvent; |
| import com.google.devtools.build.lib.runtime.CommandEnvironment; |
| import com.google.devtools.build.lib.runtime.ProcessWrapper; |
| import com.google.devtools.build.lib.sandbox.SandboxHelpers.SandboxInputs; |
| import com.google.devtools.build.lib.sandbox.SandboxHelpers.SandboxOutputs; |
| import com.google.devtools.build.lib.server.FailureDetails.Sandbox.Code; |
| import com.google.devtools.build.lib.shell.Command; |
| import com.google.devtools.build.lib.shell.CommandException; |
| import com.google.devtools.build.lib.util.OS; |
| import com.google.devtools.build.lib.util.ProcessUtils; |
| import com.google.devtools.build.lib.vfs.Path; |
| import com.google.devtools.build.lib.vfs.PathFragment; |
| import com.google.devtools.build.lib.vfs.Root; |
| import java.io.ByteArrayInputStream; |
| import java.io.ByteArrayOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.nio.charset.Charset; |
| import java.time.Duration; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.UUID; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.atomic.AtomicReference; |
| |
| /** Spawn runner that uses Docker to execute a local subprocess. */ |
| final class DockerSandboxedSpawnRunner extends AbstractSandboxSpawnRunner { |
| |
| // The name of the container image entry in the Platform proto |
| // (see third_party/googleapis/devtools/remoteexecution/*/remote_execution.proto and |
| // remote_default_exec_properties in |
| // src/main/java/com/google/devtools/build/lib/remote/RemoteOptions.java) |
| private static final String CONTAINER_IMAGE_ENTRY_NAME = "container-image"; |
| private static final String DOCKER_IMAGE_PREFIX = "docker://"; |
| |
| /** |
| * Returns whether the darwin sandbox is supported on the local machine by running docker info. |
| * This is expensive, and we have also reports of docker hanging for a long time! |
| */ |
| public static boolean isSupported(CommandEnvironment cmdEnv, Path dockerClient) |
| throws InterruptedException { |
| boolean verbose = cmdEnv.getOptions().getOptions(SandboxOptions.class).dockerVerbose; |
| |
| if (ProcessWrapper.fromCommandEnvironment(cmdEnv) == null) { |
| if (verbose) { |
| cmdEnv |
| .getReporter() |
| .handle( |
| Event.error( |
| "Docker sandboxing is disabled because ProcessWrapper is not supported. " |
| + "This should never happen - is your Bazel binary corrupted?")); |
| } |
| return false; |
| } |
| |
| // On Linux we need to know the UID and GID that we're running as, because otherwise Docker will |
| // create files as 'root' and we can't move them to the execRoot. |
| if (OS.getCurrent() == OS.LINUX) { |
| try { |
| ProcessUtils.getuid(); |
| ProcessUtils.getgid(); |
| } catch (UnsatisfiedLinkError e) { |
| if (verbose) { |
| cmdEnv |
| .getReporter() |
| .handle( |
| Event.error( |
| "Docker sandboxing is disabled, because ProcessUtils.getuid/getgid threw an " |
| + "UnsatisfiedLinkError. This means that you're running a Bazel version " |
| + "that doesn't have JNI libraries - did you build it correctly?\n" |
| + Throwables.getStackTraceAsString(e))); |
| } |
| return false; |
| } |
| } |
| |
| Command cmd = |
| new Command( |
| new String[] {dockerClient.getPathString(), "info"}, |
| cmdEnv.getClientEnv(), |
| cmdEnv.getExecRoot().getPathFile()); |
| try { |
| cmd.execute(ByteStreams.nullOutputStream(), ByteStreams.nullOutputStream()); |
| } catch (CommandException e) { |
| if (verbose) { |
| cmdEnv |
| .getReporter() |
| .handle( |
| Event.error( |
| "Docker sandboxing is disabled, because running 'docker info' failed: " |
| + Throwables.getStackTraceAsString(e))); |
| } |
| return false; |
| } |
| |
| if (verbose) { |
| cmdEnv.getReporter().handle(Event.info("Docker sandboxing is supported")); |
| } |
| |
| return true; |
| } |
| |
| private static final ConcurrentHashMap<String, String> imageMap = new ConcurrentHashMap<>(); |
| |
| private final SandboxHelpers helpers; |
| private final Path execRoot; |
| private final ImmutableList<Root> packageRoots; |
| private final boolean allowNetwork; |
| private final Path dockerClient; |
| private final ProcessWrapper processWrapper; |
| private final Path sandboxBase; |
| private final String defaultImage; |
| private final LocalEnvProvider localEnvProvider; |
| private final String commandId; |
| private final Reporter reporter; |
| private final boolean useCustomizedImages; |
| private final TreeDeleter treeDeleter; |
| private final int uid; |
| private final int gid; |
| private final Set<UUID> containersToCleanup; |
| private final CommandEnvironment cmdEnv; |
| |
| /** |
| * Creates a sandboxed spawn runner that uses the {@code linux-sandbox} tool. |
| * |
| * @param helpers common tools and state across all spawns during sandboxed execution |
| * @param cmdEnv the command environment to use |
| * @param dockerClient path to the `docker` executable |
| * @param sandboxBase path to the sandbox base directory |
| * @param defaultImage the Docker image to use if the platform doesn't specify one |
| * @param useCustomizedImages whether to use customized images for execution |
| * @param treeDeleter scheduler for tree deletions |
| */ |
| DockerSandboxedSpawnRunner( |
| SandboxHelpers helpers, |
| CommandEnvironment cmdEnv, |
| Path dockerClient, |
| Path sandboxBase, |
| String defaultImage, |
| boolean useCustomizedImages, |
| TreeDeleter treeDeleter) { |
| super(cmdEnv); |
| this.helpers = helpers; |
| this.execRoot = cmdEnv.getExecRoot(); |
| this.packageRoots = cmdEnv.getPackageLocator().getPathEntries(); |
| this.allowNetwork = helpers.shouldAllowNetwork(cmdEnv.getOptions()); |
| this.dockerClient = dockerClient; |
| this.processWrapper = ProcessWrapper.fromCommandEnvironment(cmdEnv); |
| this.sandboxBase = sandboxBase; |
| this.defaultImage = defaultImage; |
| this.localEnvProvider = LocalEnvProvider.forCurrentOs(cmdEnv.getClientEnv()); |
| this.commandId = cmdEnv.getCommandId().toString(); |
| this.reporter = cmdEnv.getReporter(); |
| this.useCustomizedImages = useCustomizedImages; |
| this.treeDeleter = treeDeleter; |
| this.cmdEnv = cmdEnv; |
| if (OS.getCurrent() == OS.LINUX) { |
| this.uid = ProcessUtils.getuid(); |
| this.gid = ProcessUtils.getgid(); |
| } else { |
| this.uid = -1; |
| this.gid = -1; |
| } |
| this.containersToCleanup = Collections.synchronizedSet(new HashSet<>()); |
| |
| cmdEnv.getEventBus().register(this); |
| } |
| |
| @Override |
| protected SandboxedSpawn prepareSpawn(Spawn spawn, SpawnExecutionContext context) |
| throws IOException, ExecException, InterruptedException, ForbiddenActionInputException { |
| // Each invocation of "exec" gets its own sandbox base, execroot and temporary directory. |
| Path sandboxPath = |
| sandboxBase.getRelative(getName()).getRelative(Integer.toString(context.getId())); |
| sandboxPath.getParentDirectory().createDirectory(); |
| sandboxPath.createDirectory(); |
| |
| // b/64689608: The execroot of the sandboxed process must end with the workspace name, just like |
| // the normal execroot does. |
| Path sandboxExecRoot = sandboxPath.getRelative("execroot").getRelative(execRoot.getBaseName()); |
| sandboxExecRoot.getParentDirectory().createDirectory(); |
| sandboxExecRoot.createDirectory(); |
| |
| ImmutableMap<String, String> environment = |
| localEnvProvider.rewriteLocalEnv(spawn.getEnvironment(), binTools, "/tmp"); |
| |
| SandboxInputs inputs = |
| helpers.processInputFiles( |
| context.getInputMapping(PathFragment.EMPTY_FRAGMENT, /* willAccessRepeatedly= */ true), |
| execRoot, |
| execRoot, |
| packageRoots, |
| null); |
| SandboxOutputs outputs = helpers.getOutputs(spawn); |
| |
| Duration timeout = context.getTimeout(); |
| |
| UUID uuid = UUID.randomUUID(); |
| |
| String baseImageName = dockerContainerFromSpawn(spawn).orElse(this.defaultImage); |
| if (baseImageName.isEmpty()) { |
| throw new UserExecException( |
| SandboxHelpers.createFailureDetail( |
| String.format( |
| "Cannot execute %s mnemonic with Docker, because no image could be found in the" |
| + " remote_execution_properties of the platform and no default image was set" |
| + " via --experimental_docker_image", |
| spawn.getMnemonic()), |
| Code.NO_DOCKER_IMAGE)); |
| } |
| |
| String customizedImageName = getOrCreateCustomizedImage(baseImageName); |
| |
| DockerCommandLineBuilder cmdLine = new DockerCommandLineBuilder(); |
| cmdLine |
| .setProcessWrapper(processWrapper) |
| .setDockerClient(dockerClient) |
| .setImageName(customizedImageName) |
| .setCommandArguments(spawn.getArguments()) |
| .setSandboxExecRoot(sandboxExecRoot) |
| .setAdditionalMounts(getSandboxOptions().sandboxAdditionalMounts) |
| .setPrivileged(getSandboxOptions().dockerPrivileged) |
| .setEnvironmentVariables(environment) |
| .setCreateNetworkNamespace( |
| !(allowNetwork |
| || Spawns.requiresNetwork(spawn, getSandboxOptions().defaultSandboxAllowNetwork))) |
| .setCommandId(commandId) |
| .setUuid(uuid); |
| // If uid / gid are -1, we are on an operating system that doesn't require us to set them on the |
| // Docker invocation. If they're 0, it means we are running as root and don't need to set them. |
| if (uid > 0) { |
| cmdLine.setUid(uid); |
| } |
| if (gid > 0) { |
| cmdLine.setGid(gid); |
| } |
| if (!timeout.isZero()) { |
| cmdLine.setTimeout(timeout); |
| } |
| |
| // If we were interrupted, it is possible that "docker run" gets killed in exactly the moment |
| // between the create and the start call, leaving behind a container that is created but never |
| // ran. This means that Docker won't automatically clean it up (as --rm only affects the start |
| // phase and has no effect on the create phase of "docker run"). |
| // We register the container UUID for cleanup, but remove the UUID if the process ran |
| // successfully. |
| containersToCleanup.add(uuid); |
| return new CopyingSandboxedSpawn( |
| sandboxPath, |
| sandboxExecRoot, |
| cmdLine.build(), |
| cmdEnv.getClientEnv(), |
| inputs, |
| outputs, |
| ImmutableSet.of(), |
| treeDeleter, |
| /* sandboxDebugPath= */ null, |
| /* statisticsPath= */ null, |
| () -> containersToCleanup.remove(uuid), |
| spawn.getMnemonic()); |
| } |
| |
| private String getOrCreateCustomizedImage(String baseImage) |
| throws UserExecException, InterruptedException { |
| // TODO(philwo) docker run implicitly does a docker pull if the image does not exist locally. |
| // Pulling an image can take a long time and a user might not be aware of that. We could check |
| // if the image exists locally (docker images -q name:tag) and if not, do a docker pull and |
| // notify the user in a similar way as when we download a http_archive. |
| // |
| // This is mostly relevant for the case where we don't build a customized image, as that prints |
| // a message when it runs. |
| |
| if (!useCustomizedImages) { |
| return baseImage; |
| } |
| |
| // If we're running as root, we can skip this step, as it's safe to assume that every image |
| // already has a built-in root user and group. |
| if (uid == 0 && gid == 0) { |
| return baseImage; |
| } |
| |
| // We only need to create a customized image, if we're running on Linux, as Docker on macOS |
| // and Windows doesn't map users from the host into the container anyway. |
| if (OS.getCurrent() != OS.LINUX) { |
| return baseImage; |
| } |
| |
| AtomicReference<UserExecException> thrownUserExecException = new AtomicReference<>(); |
| AtomicReference<InterruptedException> thrownInterruptedException = new AtomicReference<>(); |
| String result = |
| imageMap.computeIfAbsent( |
| baseImage, |
| (image) -> { |
| reporter.handle(Event.info("Preparing Docker image " + image + " for use...")); |
| String workDir = |
| PathFragment.create("/execroot") |
| .getRelative(execRoot.getBaseName()) |
| .getPathString(); |
| StringBuilder dockerfile = new StringBuilder(); |
| dockerfile.append("ARG image\n"); |
| dockerfile.append("FROM $image\n"); |
| dockerfile.append("ARG work_dir\n"); |
| dockerfile.append("RUN mkdir --parents $work_dir\n"); // could this be a VOLUME? |
| dockerfile.append("ARG group_name\n"); |
| dockerfile.append("ARG gid\n"); |
| dockerfile.append("RUN groupadd --non-unique --gid $gid $group_name\n"); |
| dockerfile.append("ARG user_name\n"); |
| dockerfile.append("ARG uid\n"); |
| dockerfile.append( |
| "RUN useradd --non-unique --no-log-init --create-home --gid $gid --home-dir" |
| + " $work_dir --no-user-group --uid" |
| + " $uid" |
| + " $user_name\n"); // we've already created home above? |
| dockerfile.append( |
| "RUN chown --recursive $uid:$gid $work_dir\n"); // if we create home with useradd |
| // it'd already have the right |
| // ownership |
| dockerfile.append("USER $user_name:$group_name\n"); |
| dockerfile.append("ENV HOME $work_dir\n"); |
| dockerfile.append("ENV USER $user_name\n"); |
| dockerfile.append("WORKDIR $work_dir\n"); |
| try { |
| return executeCommand( |
| ImmutableList.of( |
| dockerClient.getPathString(), |
| "build", |
| "--build-arg", |
| String.format("image=%s", image), |
| "--build-arg", |
| String.format("work_dir=%s", workDir), |
| "--build-arg", |
| String.format("group_name=%s", "bazelbuild"), |
| "--build-arg", |
| String.format("gid=%d", gid), |
| "--build-arg", |
| String.format("user_name=%s", "bazelbuild"), |
| "--build-arg", |
| String.format("uid=%d", uid), |
| "-q", |
| "-"), |
| new ByteArrayInputStream( |
| dockerfile.toString().getBytes(Charset.defaultCharset()))); |
| } catch (UserExecException e) { |
| thrownUserExecException.set(e); |
| return null; |
| } catch (InterruptedException e) { |
| thrownInterruptedException.set(e); |
| return null; |
| } |
| }); |
| if (thrownUserExecException.get() != null) { |
| throw thrownUserExecException.get(); |
| } |
| if (thrownInterruptedException.get() != null) { |
| throw thrownInterruptedException.get(); |
| } |
| return result; |
| } |
| |
| private String executeCommand(List<String> cmdLine, InputStream stdIn) |
| throws UserExecException, InterruptedException { |
| ByteArrayOutputStream stdOut = new ByteArrayOutputStream(); |
| ByteArrayOutputStream stdErr = new ByteArrayOutputStream(); |
| |
| // Docker might need the $HOME and $PATH variables in order to be able to use advanced |
| // authentication mechanisms (e.g. for Google Cloud), thus we pass in the client env. |
| Command cmd = |
| new Command(cmdLine.toArray(new String[0]), cmdEnv.getClientEnv(), execRoot.getPathFile()); |
| try { |
| cmd.executeAsync(stdIn, stdOut, stdErr, Command.KILL_SUBPROCESS_ON_INTERRUPT).get(); |
| } catch (CommandException e) { |
| String message = String.format("Running command %s failed: %s", cmd.toDebugString(), stdErr); |
| throw new UserExecException( |
| e, SandboxHelpers.createFailureDetail(message, Code.DOCKER_COMMAND_FAILURE)); |
| } |
| return stdOut.toString().trim(); |
| } |
| |
| private Optional<String> dockerContainerFromSpawn(Spawn spawn) throws ExecException { |
| Platform platform = |
| PlatformUtils.getPlatformProto(spawn, cmdEnv.getOptions().getOptions(RemoteOptions.class)); |
| |
| if (platform != null) { |
| try { |
| return platform |
| .getPropertiesList() |
| .stream() |
| .filter(p -> p.getName().equals(CONTAINER_IMAGE_ENTRY_NAME)) |
| .map(p -> p.getValue()) |
| .filter(r -> r.startsWith(DOCKER_IMAGE_PREFIX)) |
| .map(r -> r.substring(DOCKER_IMAGE_PREFIX.length())) |
| .collect(MoreCollectors.toOptional()); |
| } catch (IllegalArgumentException e) { |
| throw new IllegalArgumentException( |
| String.format( |
| "Platform %s contained multiple container-image entries, but only one is allowed.", |
| spawn.getExecutionPlatform().label()), |
| e); |
| } |
| } else { |
| return Optional.empty(); |
| } |
| } |
| |
| // Remove all Docker containers that might be stuck in "Created" state and weren't automatically |
| // cleaned up by Docker itself. |
| public void cleanup() throws InterruptedException { |
| if (containersToCleanup == null || containersToCleanup.isEmpty()) { |
| return; |
| } |
| |
| ArrayList<String> cmdLine = new ArrayList<>(); |
| cmdLine.add(dockerClient.getPathString()); |
| cmdLine.add("rm"); |
| cmdLine.add("-fv"); |
| for (UUID uuid : containersToCleanup) { |
| cmdLine.add(uuid.toString()); |
| } |
| |
| Command cmd = |
| new Command(cmdLine.toArray(new String[0]), cmdEnv.getClientEnv(), execRoot.getPathFile()); |
| |
| try { |
| cmd.execute(); |
| } catch (CommandException e) { |
| // This is to be expected, as not all UUIDs that we pass to "docker rm" will still be alive |
| // when this method is called. However, it will successfully remove all the containers that |
| // *are* still there, even when the command exits with an error. |
| } |
| |
| containersToCleanup.clear(); |
| } |
| |
| @Subscribe |
| public void commandComplete(@SuppressWarnings("unused") CommandCompleteEvent event) { |
| try { |
| cleanup(); |
| } catch (InterruptedException e) { |
| cmdEnv.getReporter().handle(Event.error("Interrupted while cleaning up docker sandbox")); |
| Thread.currentThread().interrupt(); |
| } |
| } |
| |
| @Override |
| public String getName() { |
| return "docker"; |
| } |
| } |