diff options
author | 2018-04-27 13:14:44 -0700 | |
---|---|---|
committer | 2018-04-27 13:15:52 -0700 | |
commit | ff726ffa222594b9aa2b9b518ac8453763f8432a (patch) | |
tree | b1921f095cf8f908bfa784cca3462d70f513a98b /src/main/java/com/google/devtools/build/lib/sandbox | |
parent | aab9868f535e3a08e272570fed5fec3c51cd384b (diff) |
sandbox: Add support for running actions inside Docker containers.
RELNOTES: Bazel now supports running actions inside Docker containers.
To use this feature, run "bazel build --spawn_strategy=docker
--experimental_docker_image=myimage:latest".
PiperOrigin-RevId: 194582691
Diffstat (limited to 'src/main/java/com/google/devtools/build/lib/sandbox')
6 files changed, 743 insertions, 0 deletions
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/BUILD b/src/main/java/com/google/devtools/build/lib/sandbox/BUILD index d18cd10d65..ae11fa47c3 100644 --- a/src/main/java/com/google/devtools/build/lib/sandbox/BUILD +++ b/src/main/java/com/google/devtools/build/lib/sandbox/BUILD @@ -20,6 +20,7 @@ java_library( "//src/main/java/com/google/devtools/build/lib:events", "//src/main/java/com/google/devtools/build/lib:io", "//src/main/java/com/google/devtools/build/lib:packages-internal", + "//src/main/java/com/google/devtools/build/lib:process_util", "//src/main/java/com/google/devtools/build/lib:runtime", "//src/main/java/com/google/devtools/build/lib:util", "//src/main/java/com/google/devtools/build/lib/actions", @@ -34,5 +35,7 @@ java_library( "//third_party:auto_value", "//third_party:guava", "//third_party:jsr305", + "//third_party/protobuf:protobuf_java", + "@googleapis//:google_devtools_remoteexecution_v1test_remote_execution_java_proto", ], ) diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/DockerCommandLineBuilder.java b/src/main/java/com/google/devtools/build/lib/sandbox/DockerCommandLineBuilder.java new file mode 100644 index 0000000000..caf7596c16 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/sandbox/DockerCommandLineBuilder.java @@ -0,0 +1,175 @@ +// 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 com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.devtools.build.lib.runtime.ProcessWrapperUtil; +import com.google.devtools.build.lib.vfs.Path; +import com.google.devtools.build.lib.vfs.PathFragment; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.UUID; + +final class DockerCommandLineBuilder { + private Path processWrapper; + private Path dockerClient; + private String imageName; + private List<String> commandArguments; + private Path sandboxExecRoot; + private Map<String, String> environmentVariables; + private Duration timeout; + private Duration killDelay; + private boolean createNetworkNamespace; + private UUID uuid; + private int uid; + private int gid; + private String commandId; + private boolean privileged; + + public DockerCommandLineBuilder setProcessWrapper(Path processWrapper) { + this.processWrapper = processWrapper; + return this; + } + + public DockerCommandLineBuilder setDockerClient(Path dockerClient) { + this.dockerClient = dockerClient; + return this; + } + + public DockerCommandLineBuilder setImageName(String imageName) { + this.imageName = imageName; + return this; + } + + public DockerCommandLineBuilder setCommandArguments(List<String> commandArguments) { + this.commandArguments = commandArguments; + return this; + } + + public DockerCommandLineBuilder setSandboxExecRoot(Path sandboxExecRoot) { + this.sandboxExecRoot = sandboxExecRoot; + return this; + } + + public DockerCommandLineBuilder setEnvironmentVariables( + Map<String, String> environmentVariables) { + this.environmentVariables = environmentVariables; + return this; + } + + public DockerCommandLineBuilder setTimeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + public DockerCommandLineBuilder setKillDelay(Duration killDelay) { + this.killDelay = killDelay; + return this; + } + + public DockerCommandLineBuilder setCreateNetworkNamespace(boolean createNetworkNamespace) { + this.createNetworkNamespace = createNetworkNamespace; + return this; + } + + public DockerCommandLineBuilder setUuid(UUID uuid) { + this.uuid = uuid; + return this; + } + + public DockerCommandLineBuilder setUid(int uid) { + this.uid = uid; + return this; + } + + public DockerCommandLineBuilder setGid(int gid) { + this.gid = gid; + return this; + } + + public DockerCommandLineBuilder setCommandId(String commandId) { + this.commandId = commandId; + return this; + } + + public DockerCommandLineBuilder setPrivileged(boolean privileged) { + this.privileged = privileged; + return this; + } + + public List<String> build() { + Preconditions.checkNotNull(sandboxExecRoot, "sandboxExecRoot must be set"); + Preconditions.checkState(!imageName.isEmpty(), "imageName must be set"); + Preconditions.checkState(!commandArguments.isEmpty(), "commandArguments must be set"); + + ImmutableList.Builder<String> dockerCmdLine = ImmutableList.builder(); + + dockerCmdLine.add(dockerClient.getPathString()); + dockerCmdLine.add("run"); + dockerCmdLine.add("--rm"); + if (createNetworkNamespace) { + dockerCmdLine.add("--network=none"); + } else { + dockerCmdLine.add("--network=host"); + } + if (privileged) { + dockerCmdLine.add("--privileged"); + } + for (Entry<String, String> env : environmentVariables.entrySet()) { + dockerCmdLine.add("-e", env.getKey() + "=" + env.getValue()); + } + PathFragment execRootInsideDocker = + PathFragment.create("/execroot/").getRelative(sandboxExecRoot.getBaseName()); + dockerCmdLine.add( + "-v", sandboxExecRoot.getPathString() + ":" + execRootInsideDocker.getPathString()); + dockerCmdLine.add("-w", execRootInsideDocker.getPathString()); + + StringBuilder uidGidFlagBuilder = new StringBuilder(); + if (uid != 0) { + uidGidFlagBuilder.append(uid); + } + if (gid != 0) { + uidGidFlagBuilder.append(":"); + uidGidFlagBuilder.append(gid); + } + String uidGidFlag = uidGidFlagBuilder.toString(); + if (!uidGidFlag.isEmpty()) { + dockerCmdLine.add("-u", uidGidFlagBuilder.toString()); + } + + if (!commandId.isEmpty()) { + dockerCmdLine.add("-l", "command_id=" + commandId); + } + if (uuid != null) { + dockerCmdLine.add("--name", uuid.toString()); + } + dockerCmdLine.add(imageName); + dockerCmdLine.addAll(commandArguments); + + ProcessWrapperUtil.CommandLineBuilder processWrapperCmdLine = + ProcessWrapperUtil.commandLineBuilder( + this.processWrapper.getPathString(), dockerCmdLine.build()); + if (timeout != null) { + processWrapperCmdLine.setTimeout(timeout); + } + if (killDelay != null) { + processWrapperCmdLine.setKillDelay(killDelay); + } + return processWrapperCmdLine.build(); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/DockerSandboxedSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/DockerSandboxedSpawnRunner.java new file mode 100644 index 0000000000..6d4fda2876 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/sandbox/DockerSandboxedSpawnRunner.java @@ -0,0 +1,427 @@ +// 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 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.Spawn; +import com.google.devtools.build.lib.actions.SpawnResult; +import com.google.devtools.build.lib.actions.Spawns; +import com.google.devtools.build.lib.actions.UserExecException; +import com.google.devtools.build.lib.events.Event; +import com.google.devtools.build.lib.events.Reporter; +import com.google.devtools.build.lib.exec.local.LocalEnvProvider; +import com.google.devtools.build.lib.exec.local.PosixLocalEnvProvider; +import com.google.devtools.build.lib.runtime.CommandCompleteEvent; +import com.google.devtools.build.lib.runtime.CommandEnvironment; +import com.google.devtools.build.lib.runtime.ProcessWrapperUtil; +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.remoteexecution.v1test.Platform; +import com.google.protobuf.TextFormat; +import com.google.protobuf.TextFormat.ParseException; +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.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** 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 + // experimental_remote_platform_override 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://"; + + public static boolean isSupported(CommandEnvironment cmdEnv, Path dockerClient) { + boolean verbose = cmdEnv.getOptions().getOptions(SandboxOptions.class).dockerVerbose; + + if (!ProcessWrapperUtil.isSupported(cmdEnv)) { + if (verbose) { + cmdEnv + .getReporter() + .handle( + Event.error( + "Docker sandboxing is disabled, because ProcessWrapperUtil.isSupported " + + "returned false. 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"}, + ImmutableMap.of(), + 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 Path execRoot; + private final boolean allowNetwork; + private final Path dockerClient; + private final Path processWrapper; + private final Path sandboxBase; + private final String defaultImage; + private final LocalEnvProvider localEnvProvider; + private final Duration timeoutKillDelay; + private final String commandId; + private final Reporter reporter; + private final boolean useCustomizedImages; + private final int uid; + private final int gid; + private final List<UUID> containersToCleanup; + + /** + * Creates a sandboxed spawn runner that uses the {@code linux-sandbox} tool. + * + * @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 timeoutKillDelay an additional grace period before killing timing out commands + * @param useCustomizedImages whether to use customized images for execution + */ + DockerSandboxedSpawnRunner( + CommandEnvironment cmdEnv, + Path dockerClient, + Path sandboxBase, + String defaultImage, + Duration timeoutKillDelay, + boolean useCustomizedImages) { + super(cmdEnv); + this.execRoot = cmdEnv.getExecRoot(); + this.allowNetwork = SandboxHelpers.shouldAllowNetwork(cmdEnv.getOptions()); + this.dockerClient = dockerClient; + this.processWrapper = ProcessWrapperUtil.getProcessWrapper(cmdEnv); + this.sandboxBase = sandboxBase; + this.defaultImage = defaultImage; + this.localEnvProvider = new PosixLocalEnvProvider(cmdEnv.getClientEnv()); + this.timeoutKillDelay = timeoutKillDelay; + this.commandId = cmdEnv.getCommandId().toString(); + this.reporter = cmdEnv.getReporter(); + this.useCustomizedImages = useCustomizedImages; + if (OS.getCurrent() == OS.LINUX) { + this.uid = ProcessUtils.getuid(); + this.gid = ProcessUtils.getgid(); + } else { + this.uid = -1; + this.gid = -1; + } + this.containersToCleanup = Collections.synchronizedList(new ArrayList<>()); + + cmdEnv.getEventBus().register(this); + } + + @Override + protected SpawnResult actuallyExec(Spawn spawn, SpawnExecutionContext context) + throws IOException, ExecException, InterruptedException { + // 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(); + + Map<String, String> environment = + localEnvProvider.rewriteLocalEnv(spawn.getEnvironment(), execRoot, "/tmp"); + + ImmutableSet<PathFragment> outputs = SandboxHelpers.getOutputFiles(spawn); + Duration timeout = context.getTimeout(); + + UUID uuid = UUID.randomUUID(); + + String baseImageName = dockerContainerFromSpawn(spawn).orElse(this.defaultImage); + if (baseImageName.isEmpty()) { + throw new UserExecException( + 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())); + } + + String customizedImageName = getOrCreateCustomizedImage(baseImageName); + if (customizedImageName == null) { + throw new UserExecException("Could not prepare Docker image for execution"); + } + + DockerCommandLineBuilder cmdLine = new DockerCommandLineBuilder(); + cmdLine + .setProcessWrapper(processWrapper) + .setDockerClient(dockerClient) + .setImageName(customizedImageName) + .setCommandArguments(spawn.getArguments()) + .setSandboxExecRoot(sandboxExecRoot) + .setPrivileged(getSandboxOptions().dockerPrivileged) + .setEnvironmentVariables(environment) + .setKillDelay(timeoutKillDelay) + .setCreateNetworkNamespace(!(allowNetwork || Spawns.requiresNetwork(spawn))) + .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); + } + + SandboxedSpawn sandbox = + new CopyingSandboxedSpawn( + sandboxPath, + sandboxExecRoot, + cmdLine.build(), + environment, + SandboxHelpers.getInputFiles(spawn, context, execRoot), + outputs, + ImmutableSet.of()); + + try { + return runSpawn(spawn, sandbox, context, execRoot, timeout, null); + } catch (InterruptedException e) { + // 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 add the container UUID to a list and clean them up after the execution is over. + containersToCleanup.add(uuid); + throw e; + } + } + + private String getOrCreateCustomizedImage(String baseImage) { + // 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; + } + + return 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(String.format("FROM %s\n", image)); + dockerfile.append(String.format("RUN [\"mkdir\", \"-p\", \"%s\"]\n", workDir)); + // TODO(philwo) this will fail if a user / group with the given uid / gid already exists + // in the container. For now this seems reasonably unlikely, but we'll have to come up + // with a better way. + if (gid > 0) { + dockerfile.append( + String.format("RUN [\"groupadd\", \"-g\", \"%d\", \"bazelbuild\"]\n", gid)); + } + if (uid > 0) { + dockerfile.append( + String.format( + "RUN [\"useradd\", \"-m\", \"-g\", \"%d\", \"-d\", \"%s\", \"-N\", \"-u\", " + + "\"%d\", \"bazelbuild\"]\n", + gid, workDir, uid)); + } + dockerfile.append( + String.format("RUN [\"chown\", \"-R\", \"%d:%d\", \"%s\"]\n", uid, gid, workDir)); + dockerfile.append(String.format("USER %d:%d\n", uid, gid)); + dockerfile.append(String.format("ENV HOME %s\n", workDir)); + if (uid > 0) { + dockerfile.append(String.format("ENV USER bazelbuild\n")); + } + dockerfile.append(String.format("WORKDIR %s\n", workDir)); + try { + return executeCommand( + ImmutableList.of(dockerClient.getPathString(), "build", "-q", "-"), + new ByteArrayInputStream(dockerfile.toString().getBytes(Charset.defaultCharset()))); + } catch (UserExecException e) { + reporter.handle(Event.error(e.getMessage())); + return null; + } + }); + } + + private String executeCommand(List<String> cmdLine, InputStream stdIn) throws UserExecException { + ByteArrayOutputStream stdOut = new ByteArrayOutputStream(); + ByteArrayOutputStream stdErr = new ByteArrayOutputStream(); + Command cmd = + new Command(cmdLine.toArray(new String[0]), ImmutableMap.of(), execRoot.getPathFile()); + try { + cmd.executeAsync(stdIn, stdOut, stdErr, Command.KILL_SUBPROCESS_ON_INTERRUPT).get(); + } catch (CommandException e) { + throw new UserExecException("Running command " + cmd.toDebugString() + " failed: " + stdErr); + } + return stdOut.toString().trim(); + } + + private Optional<String> dockerContainerFromSpawn(Spawn spawn) { + Platform platform = null; + // TODO(philwo) Figure out if this is the right mechanism to specify a Docker image per action. + String platformDescription = spawn.getExecutionPlatform().remoteExecutionProperties(); + if (platformDescription != null) { + try { + Platform.Builder platformBuilder = Platform.newBuilder(); + TextFormat.getParser().merge(platformDescription, platformBuilder); + platform = platformBuilder.build(); + } catch (ParseException e) { + throw new IllegalArgumentException( + String.format( + "Failed to parse remote_execution_properties from platform %s", + spawn.getExecutionPlatform().label()), + e); + } + } + + 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())); + } + } 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() { + 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]), ImmutableMap.of(), 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(CommandCompleteEvent event) { + cleanup(); + } + + @Override + public String getName() { + return "docker"; + } +} diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/DockerSandboxedStrategy.java b/src/main/java/com/google/devtools/build/lib/sandbox/DockerSandboxedStrategy.java new file mode 100644 index 0000000000..ba38fe67dc --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/sandbox/DockerSandboxedStrategy.java @@ -0,0 +1,37 @@ +// 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 com.google.devtools.build.lib.actions.ExecutionStrategy; +import com.google.devtools.build.lib.actions.SpawnActionContext; +import com.google.devtools.build.lib.exec.AbstractSpawnStrategy; +import com.google.devtools.build.lib.exec.SpawnRunner; +import com.google.devtools.build.lib.vfs.Path; + +/** Strategy that uses Docker to execute a process. */ +// TODO(ulfjack): This class only exists for this annotation. Find a better way to handle this! +@ExecutionStrategy( + name = {"docker"}, + contextType = SpawnActionContext.class) +public final class DockerSandboxedStrategy extends AbstractSpawnStrategy { + DockerSandboxedStrategy(Path execRoot, SpawnRunner spawnRunner) { + super(execRoot, spawnRunner); + } + + @Override + public String toString() { + return "docker"; + } +} diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxActionContextProvider.java b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxActionContextProvider.java index 0acbe064b4..4e47405d62 100644 --- a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxActionContextProvider.java +++ b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxActionContextProvider.java @@ -14,6 +14,7 @@ package com.google.devtools.build.lib.sandbox; +import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.devtools.build.lib.actions.ActionContext; import com.google.devtools.build.lib.actions.ExecException; @@ -30,10 +31,13 @@ import com.google.devtools.build.lib.exec.local.LocalSpawnRunner; import com.google.devtools.build.lib.exec.local.PosixLocalEnvProvider; import com.google.devtools.build.lib.runtime.CommandEnvironment; import com.google.devtools.build.lib.util.OS; +import com.google.devtools.build.lib.vfs.FileSystem; import com.google.devtools.build.lib.vfs.Path; import com.google.devtools.common.options.OptionsProvider; +import java.io.File; import java.io.IOException; import java.time.Duration; +import java.util.Optional; import javax.annotation.Nullable; /** @@ -67,6 +71,29 @@ final class SandboxActionContextProvider extends ActionContextProvider { contexts.add(new ProcessWrapperSandboxedStrategy(cmdEnv.getExecRoot(), spawnRunner)); } + // This strategy uses Docker to execute spawns. It should work on all platforms that support + // Docker. + getPathToDockerClient(cmdEnv) + .ifPresent( + dockerClient -> { + if (DockerSandboxedSpawnRunner.isSupported(cmdEnv, dockerClient)) { + String defaultImage = options.getOptions(SandboxOptions.class).dockerImage; + boolean useCustomizedImages = + options.getOptions(SandboxOptions.class).dockerUseCustomizedImages; + SpawnRunner spawnRunner = + withFallback( + cmdEnv, + new DockerSandboxedSpawnRunner( + cmdEnv, + dockerClient, + sandboxBase, + defaultImage, + timeoutKillDelay, + useCustomizedImages)); + contexts.add(new DockerSandboxedStrategy(cmdEnv.getExecRoot(), spawnRunner)); + } + }); + // This is the preferred sandboxing strategy on Linux. if (LinuxSandboxedSpawnRunner.isSupported(cmdEnv)) { SpawnRunner spawnRunner = @@ -88,6 +115,32 @@ final class SandboxActionContextProvider extends ActionContextProvider { return new SandboxActionContextProvider(contexts.build()); } + private static Optional<Path> getPathToDockerClient(CommandEnvironment cmdEnv) { + String path = cmdEnv.getClientEnv().getOrDefault("PATH", ""); + + Splitter pathSplitter = + Splitter.on(OS.getCurrent() == OS.WINDOWS ? ';' : ':').trimResults().omitEmptyStrings(); + + FileSystem fs = cmdEnv.getRuntime().getFileSystem(); + + for (String pathElement : pathSplitter.split(path)) { + // Sometimes the PATH contains the non-absolute entry "." - this resolves it against the + // current working directory. + pathElement = new File(pathElement).getAbsolutePath(); + try { + for (Path dentry : fs.getPath(pathElement).getDirectoryEntries()) { + if (dentry.getBaseName().replace(".exe", "").equals("docker")) { + return Optional.of(dentry); + } + } + } catch (IOException e) { + continue; + } + } + + return Optional.empty(); + } + private static SpawnRunner withFallback(CommandEnvironment env, SpawnRunner sandboxSpawnRunner) { return new SandboxFallbackSpawnRunner(sandboxSpawnRunner, createFallbackRunner(env)); } diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java index 6a5b79f0fc..6025944627 100644 --- a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java +++ b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java @@ -217,4 +217,52 @@ public class SandboxOptions extends OptionsBase { + "locally executed actions which use sandboxing" ) public boolean collectLocalSandboxExecutionStatistics; + + @Option( + name = "experimental_docker_image", + defaultValue = "", + documentationCategory = OptionDocumentationCategory.EXECUTION_STRATEGY, + effectTags = {OptionEffectTag.EXECUTION}, + help = + "Specify a Docker image name (e.g. \"ubuntu:latest\") that should be used to execute " + + "a sandboxed action when using the docker strategy and the action itself doesn't " + + "already have a container-image attribute in its remote_execution_properties in the " + + "platform description. The value of this flag is passed verbatim to 'docker run', so " + + "it supports the same syntax and mechanisms as Docker itself." + ) + public String dockerImage; + + @Option( + name = "experimental_docker_use_customized_images", + defaultValue = "true", + documentationCategory = OptionDocumentationCategory.EXECUTION_STRATEGY, + effectTags = {OptionEffectTag.EXECUTION}, + help = + "If enabled, injects the uid and gid of the current user into the Docker image before " + + "using it. This is required if your build / tests depend on the user having a name " + + "and home directory inside the container. This is on by default, but you can disable " + + "it in case the automatic image customization feature doesn't work in your case or " + + "you know that you don't need it." + ) + public boolean dockerUseCustomizedImages; + + @Option( + name = "experimental_docker_verbose", + defaultValue = "false", + documentationCategory = OptionDocumentationCategory.EXECUTION_STRATEGY, + effectTags = {OptionEffectTag.EXECUTION}, + help = + "If enabled, Bazel will print more verbose messages about the Docker sandbox strategy.") + public boolean dockerVerbose; + + @Option( + name = "experimental_docker_privileged", + defaultValue = "false", + documentationCategory = OptionDocumentationCategory.EXECUTION_STRATEGY, + effectTags = {OptionEffectTag.EXECUTION}, + help = + "If enabled, Bazel will pass the --privileged flag to 'docker run' when running actions. " + + "This might be required by your build, but it might also result in reduced " + + "hermeticity.") + public boolean dockerPrivileged; } |