[7.0.1] Print interactive sandboxed shell command with `--sandbox_debug` (#20734)

The `--sandbox_debug` output for the Linux sandbox now also contains a
copyable command that drops the user into an interactive shell in an
identical sandboxed environment. This is in particular meant to address
the increased complexity of the bind mount structure incurred by the
flip of `--incompatible_sandbox_hermetic_tmp`.

Closes #20708.

Commit
https://github.com/bazelbuild/bazel/commit/48ce49b6234f80f170eadddff2882fdebcf8e5da

PiperOrigin-RevId: 595457357
Change-Id: I6dfd410895b93fce67b9666c76c5f7757e77dc3a

Co-authored-by: Fabian Meumertzheim <fabian@meumertzhe.im>
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java
index 2a0fa88..6186871 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java
@@ -15,6 +15,7 @@
 package com.google.devtools.build.lib.sandbox;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
@@ -59,7 +60,8 @@
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Map;
-import javax.annotation.Nullable;
+import java.util.Optional;
+import java.util.stream.Stream;
 
 /** Abstract common ancestor for sandbox spawn runners implementing the common parts. */
 abstract class AbstractSandboxSpawnRunner implements SpawnRunner {
@@ -224,7 +226,7 @@
       StringBuilder msg = new StringBuilder("Action failed to execute: java.io.IOException: ");
       msg.append(exceptionMsg);
       msg.append("\n");
-      if (sandboxDebugOutput != null) {
+      if (!sandboxDebugOutput.isEmpty()) {
         msg.append("Sandbox debug output:\n");
         msg.append(sandboxDebugOutput);
         msg.append("\n");
@@ -294,12 +296,12 @@
     }
 
     String sandboxDebugOutput = getSandboxDebugOutput(sandbox);
-    if (sandboxDebugOutput != null) {
+    if (!sandboxDebugOutput.isEmpty()) {
       reporter.handle(
           Event.of(
               EventKind.DEBUG,
               String.format(
-                  "Sandbox debug output for %s %s: %s",
+                  "Sandbox debug output for %s %s:\n%s",
                   originalSpawn.getMnemonic(),
                   originalSpawn.getTargetLabel(),
                   sandboxDebugOutput)));
@@ -334,18 +336,21 @@
     return spawnResultBuilder.build();
   }
 
-  @Nullable
   private String getSandboxDebugOutput(SandboxedSpawn sandbox) throws IOException {
+    Optional<String> sandboxDebugOutput = Optional.empty();
     Path sandboxDebugPath = sandbox.getSandboxDebugPath();
     if (sandboxDebugPath != null && sandboxDebugPath.exists()) {
       try (InputStream inputStream = sandboxDebugPath.getInputStream()) {
         String msg = new String(inputStream.readAllBytes(), UTF_8);
         if (!msg.isEmpty()) {
-          return msg;
+          sandboxDebugOutput = Optional.of(msg);
         }
       }
     }
-    return null;
+    Optional<String> interactiveDebugInstructions = sandbox.getInteractiveDebugInstructions();
+    return Stream.of(sandboxDebugOutput, interactiveDebugInstructions)
+        .flatMap(Optional::stream)
+        .collect(joining("\n"));
   }
 
   private boolean wasTimeout(Duration timeout, Duration wallTime) {
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 87ac37c..091dc91 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/BUILD
@@ -72,6 +72,7 @@
         ":sandbox_helpers",
         ":sandbox_options",
         "//src/main/java/com/google/devtools/build/lib/exec:tree_deleter",
+        "//src/main/java/com/google/devtools/build/lib/util:command",
         "//src/main/java/com/google/devtools/build/lib/util:describable_execution_unit",
         "//src/main/java/com/google/devtools/build/lib/vfs",
         "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/DarwinSandboxedSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/DarwinSandboxedSpawnRunner.java
index fad5cfa..40cd349 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/DarwinSandboxedSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/DarwinSandboxedSpawnRunner.java
@@ -258,29 +258,30 @@
         allowNetwork
             || Spawns.requiresNetwork(spawn, getSandboxOptions().defaultSandboxAllowNetwork);
 
-      return new SymlinkedSandboxedSpawn(
-          sandboxPath,
-          sandboxExecRoot,
-          commandLine,
-          environment,
-          inputs,
-          outputs,
-          writableDirs,
-          treeDeleter,
-          /* sandboxDebugPath= */ null,
-          statisticsPath,
-          spawn.getMnemonic()) {
-        @Override
-        public void createFileSystem() throws IOException, InterruptedException {
-          super.createFileSystem();
-          writeConfig(
-              sandboxConfigPath,
-              writableDirs,
-              getInaccessiblePaths(),
-              allowNetworkForThisSpawn,
-              statisticsPath);
-        }
-      };
+    return new SymlinkedSandboxedSpawn(
+        sandboxPath,
+        sandboxExecRoot,
+        commandLine,
+        environment,
+        inputs,
+        outputs,
+        writableDirs,
+        treeDeleter,
+        /* sandboxDebugPath= */ null,
+        statisticsPath,
+        /* interactiveDebugArguments= */ null,
+        spawn.getMnemonic()) {
+      @Override
+      public void createFileSystem() throws IOException, InterruptedException {
+        super.createFileSystem();
+        writeConfig(
+            sandboxConfigPath,
+            writableDirs,
+            getInaccessiblePaths(),
+            allowNetworkForThisSpawn,
+            statisticsPath);
+      }
+    };
   }
 
   private void writeConfig(
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilder.java b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilder.java
index cf80265..967c08b 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilder.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilder.java
@@ -51,7 +51,6 @@
   }
 
   private final Path linuxSandboxPath;
-  private final List<String> commandArguments;
   private Path hermeticSandboxPath;
   private Path workingDirectory;
   private Duration timeout;
@@ -72,15 +71,13 @@
   private boolean sigintSendsSigterm = false;
   private String cgroupsDir;
 
-  private LinuxSandboxCommandLineBuilder(Path linuxSandboxPath, List<String> commandArguments) {
+  private LinuxSandboxCommandLineBuilder(Path linuxSandboxPath) {
     this.linuxSandboxPath = linuxSandboxPath;
-    this.commandArguments = commandArguments;
   }
 
   /** Returns a new command line builder for the {@code linux-sandbox} tool. */
-  public static LinuxSandboxCommandLineBuilder commandLineBuilder(
-      Path linuxSandboxPath, List<String> commandArguments) {
-    return new LinuxSandboxCommandLineBuilder(linuxSandboxPath, commandArguments);
+  public static LinuxSandboxCommandLineBuilder commandLineBuilder(Path linuxSandboxPath) {
+    return new LinuxSandboxCommandLineBuilder(linuxSandboxPath);
   }
 
   /**
@@ -247,7 +244,7 @@
   }
 
   /** Builds the command line to invoke a specific command using the {@code linux-sandbox} tool. */
-  public ImmutableList<String> build() {
+  public ImmutableList<String> buildForCommand(List<String> commandArguments) {
     Preconditions.checkState(
         !(this.useFakeUsername && this.useFakeRoot),
         "useFakeUsername and useFakeRoot are exclusive");
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java
index 2d593e7..df6f5fb 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java
@@ -106,10 +106,9 @@
       throws InterruptedException {
     LocalExecutionOptions options = cmdEnv.getOptions().getOptions(LocalExecutionOptions.class);
     ImmutableList<String> linuxSandboxArgv =
-        LinuxSandboxCommandLineBuilder.commandLineBuilder(
-                linuxSandbox, ImmutableList.of("/bin/true"))
+        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandbox)
             .setTimeout(options.getLocalSigkillGraceSeconds())
-            .build();
+            .buildForCommand(ImmutableList.of("/bin/true"));
     ImmutableMap<String, String> env = ImmutableMap.of();
     Path execRoot = cmdEnv.getExecRoot();
     File cwd = execRoot.getPathFile();
@@ -309,7 +308,7 @@
     boolean createNetworkNamespace =
         !(allowNetwork || Spawns.requiresNetwork(spawn, sandboxOptions.defaultSandboxAllowNetwork));
     LinuxSandboxCommandLineBuilder commandLineBuilder =
-        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandbox, spawn.getArguments())
+        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandbox)
             .addExecutionInfo(spawn.getExecutionInfo())
             .setWritableFilesAndDirectories(writableDirs)
             .setTmpfsDirectories(ImmutableSet.copyOf(getSandboxOptions().sandboxTmpfsPath))
@@ -354,7 +353,7 @@
       return new HardlinkedSandboxedSpawn(
           sandboxPath,
           sandboxExecRoot,
-          commandLineBuilder.build(),
+          commandLineBuilder.buildForCommand(spawn.getArguments()),
           environment,
           inputs,
           outputs,
@@ -368,7 +367,7 @@
       return new SymlinkedSandboxedSpawn(
           sandboxPath,
           sandboxExecRoot,
-          commandLineBuilder.build(),
+          commandLineBuilder.buildForCommand(spawn.getArguments()),
           environment,
           inputs,
           outputs,
@@ -376,6 +375,7 @@
           treeDeleter,
           sandboxDebugPath,
           statisticsPath,
+          makeInteractiveDebugArguments(commandLineBuilder, sandboxOptions),
           spawn.getMnemonic());
     }
   }
@@ -538,4 +538,13 @@
 
     super.cleanupSandboxBase(sandboxBase, treeDeleter);
   }
+
+  @Nullable
+  private ImmutableList<String> makeInteractiveDebugArguments(
+      LinuxSandboxCommandLineBuilder commandLineBuilder, SandboxOptions sandboxOptions) {
+    if (!sandboxOptions.sandboxDebug) {
+      return null;
+    }
+    return commandLineBuilder.buildForCommand(ImmutableList.of("/bin/sh", "-i"));
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/ProcessWrapperSandboxedSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/ProcessWrapperSandboxedSpawnRunner.java
index 2f7608d..a01a4e6 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/ProcessWrapperSandboxedSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/ProcessWrapperSandboxedSpawnRunner.java
@@ -106,18 +106,19 @@
             null);
     SandboxOutputs outputs = helpers.getOutputs(spawn);
 
-      return new SymlinkedSandboxedSpawn(
-          sandboxPath,
-          sandboxExecRoot,
-          commandLineBuilder.build(),
-          environment,
-          inputs,
-          outputs,
-          getWritableDirs(sandboxExecRoot, sandboxExecRoot, environment),
-          treeDeleter,
-          /* sandboxDebugPath= */ null,
-          statisticsPath,
-          spawn.getMnemonic());
+    return new SymlinkedSandboxedSpawn(
+        sandboxPath,
+        sandboxExecRoot,
+        commandLineBuilder.build(),
+        environment,
+        inputs,
+        outputs,
+        getWritableDirs(sandboxExecRoot, sandboxExecRoot, environment),
+        treeDeleter,
+        /* sandboxDebugPath= */ null,
+        statisticsPath,
+        /* interactiveDebugArguments= */ null,
+        spawn.getMnemonic());
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxedSpawn.java b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxedSpawn.java
index 1a355bc..4465778 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxedSpawn.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxedSpawn.java
@@ -17,6 +17,7 @@
 import com.google.devtools.build.lib.util.DescribableExecutionUnit;
 import com.google.devtools.build.lib.vfs.Path;
 import java.io.IOException;
+import java.util.Optional;
 import javax.annotation.Nullable;
 
 /**
@@ -61,4 +62,12 @@
 
   /** Deletes the sandbox directory. */
   void delete();
+
+  /**
+   * Returns user-facing instructions for starting an interactive sandboxed environment identical to
+   * the one in which this spawn is executed.
+   */
+  default Optional<String> getInteractiveDebugInstructions() {
+    return Optional.empty();
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawn.java b/src/main/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawn.java
index 57a0935..4462d8a 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawn.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawn.java
@@ -21,10 +21,13 @@
 import com.google.devtools.build.lib.exec.TreeDeleter;
 import com.google.devtools.build.lib.sandbox.SandboxHelpers.SandboxInputs;
 import com.google.devtools.build.lib.sandbox.SandboxHelpers.SandboxOutputs;
+import com.google.devtools.build.lib.util.CommandDescriptionForm;
+import com.google.devtools.build.lib.util.CommandFailureUtils;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import java.io.IOException;
 import java.util.LinkedHashSet;
+import java.util.Optional;
 import java.util.Set;
 import javax.annotation.Nullable;
 
@@ -37,6 +40,8 @@
   /** Mnemonic of the action running in this spawn. */
   private final String mnemonic;
 
+  @Nullable private final ImmutableList<String> interactiveDebugArguments;
+
   public SymlinkedSandboxedSpawn(
       Path sandboxPath,
       Path sandboxExecRoot,
@@ -48,6 +53,7 @@
       TreeDeleter treeDeleter,
       @Nullable Path sandboxDebugPath,
       @Nullable Path statisticsPath,
+      @Nullable ImmutableList<String> interactiveDebugArguments,
       String mnemonic) {
     super(
         sandboxPath,
@@ -62,6 +68,7 @@
         statisticsPath,
         mnemonic);
     this.mnemonic = isNullOrEmpty(mnemonic) ? "_NoMnemonic_" : mnemonic;
+    this.interactiveDebugArguments = interactiveDebugArguments;
   }
 
   @Override
@@ -96,4 +103,23 @@
     SandboxStash.stashSandbox(sandboxPath, mnemonic);
     super.delete();
   }
+
+  @Nullable
+  @Override
+  public Optional<String> getInteractiveDebugInstructions() {
+    if (interactiveDebugArguments == null) {
+      return Optional.empty();
+    }
+    return Optional.of(
+        "Run this command to start an interactive shell in an identical sandboxed environment:\n"
+            + CommandFailureUtils.describeCommand(
+                CommandDescriptionForm.COMPLETE,
+                /* prettyPrintArgs= */ false,
+                interactiveDebugArguments,
+                getEnvironment(),
+                /* environmentVariablesToClear= */ null,
+                /* cwd= */ null,
+                /* configurationChecksum= */ null,
+                /* executionPlatformLabel= */ null));
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/worker/SandboxedWorker.java b/src/main/java/com/google/devtools/build/lib/worker/SandboxedWorker.java
index 9d05091..06453e4 100644
--- a/src/main/java/com/google/devtools/build/lib/worker/SandboxedWorker.java
+++ b/src/main/java/com/google/devtools/build/lib/worker/SandboxedWorker.java
@@ -181,7 +181,7 @@
       // Mostly tests require network, and some blaze run commands, but no workers.
       LinuxSandboxCommandLineBuilder commandLineBuilder =
           LinuxSandboxCommandLineBuilder.commandLineBuilder(
-                  this.hardenedSandboxOptions.sandboxBinary(), args)
+                  this.hardenedSandboxOptions.sandboxBinary())
               .setWritableFilesAndDirectories(getWritableDirs(workDir))
               .setTmpfsDirectories(ImmutableSet.copyOf(this.hardenedSandboxOptions.tmpfsPath()))
               .setPersistentProcess(true)
@@ -202,7 +202,7 @@
         commandLineBuilder.setUseFakeUsername(true);
       }
 
-      args = commandLineBuilder.build();
+      args = commandLineBuilder.buildForCommand(args);
     }
     return createProcessBuilder(args).start();
   }
diff --git a/src/test/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilderTest.java b/src/test/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilderTest.java
index a8caa07..9f8df6d 100644
--- a/src/test/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilderTest.java
+++ b/src/test/java/com/google/devtools/build/lib/sandbox/LinuxSandboxCommandLineBuilderTest.java
@@ -53,11 +53,10 @@
         assertThrows(
             IllegalStateException.class,
             () ->
-                LinuxSandboxCommandLineBuilder.commandLineBuilder(
-                        linuxSandboxPath, commandArguments)
+                LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandboxPath)
                     .setUseFakeRoot(true)
                     .setUseFakeUsername(true)
-                    .build());
+                    .buildForCommand(commandArguments));
     assertThat(e).hasMessageThat().contains("exclusive");
   }
 
@@ -75,8 +74,8 @@
             .build();
 
     List<String> commandLine =
-        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandboxPath, commandArguments)
-            .build();
+        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandboxPath)
+            .buildForCommand(commandArguments);
 
     assertThat(commandLine).containsExactlyElementsIn(expectedCommandLine).inOrder();
   }
@@ -163,7 +162,7 @@
             .build();
 
     List<String> commandLine =
-        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandboxPath, commandArguments)
+        LinuxSandboxCommandLineBuilder.commandLineBuilder(linuxSandboxPath)
             .setWorkingDirectory(workingDirectory)
             .setStdoutPath(stdoutPath)
             .setStderrPath(stderrPath)
@@ -180,7 +179,7 @@
             .setSandboxDebugPath(sandboxDebugPath.getPathString())
             .setPersistentProcess(true)
             .setCgroupsDir(cgroupsDir)
-            .build();
+            .buildForCommand(commandArguments);
 
     assertThat(commandLine).containsExactlyElementsIn(expectedCommandLine).inOrder();
   }
diff --git a/src/test/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawnTest.java b/src/test/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawnTest.java
index 1dd8e66..dfc659b 100644
--- a/src/test/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawnTest.java
+++ b/src/test/java/com/google/devtools/build/lib/sandbox/SymlinkedSandboxedSpawnTest.java
@@ -83,6 +83,7 @@
             new SynchronousTreeDeleter(),
             /* sandboxDebugPath= */ null,
             /* statisticsPath= */ null,
+            /* interactiveDebugArguments= */ null,
             "SomeMnemonic");
 
     symlinkedExecRoot.createFileSystem();
@@ -114,6 +115,7 @@
             new SynchronousTreeDeleter(),
             /* sandboxDebugPath= */ null,
             /* statisticsPath= */ null,
+            /* interactiveDebugArguments= */ null,
             "SomeMnemonic");
     symlinkedExecRoot.createFileSystem();
 
diff --git a/src/test/java/com/google/devtools/build/lib/shell/CommandUsingLinuxSandboxTest.java b/src/test/java/com/google/devtools/build/lib/shell/CommandUsingLinuxSandboxTest.java
index 0a36c62..f4278c5 100644
--- a/src/test/java/com/google/devtools/build/lib/shell/CommandUsingLinuxSandboxTest.java
+++ b/src/test/java/com/google/devtools/build/lib/shell/CommandUsingLinuxSandboxTest.java
@@ -76,8 +76,8 @@
     ImmutableList<String> commandArguments = ImmutableList.of("echo", "sleep furiously");
 
     List<String> fullCommandLine =
-        LinuxSandboxCommandLineBuilder.commandLineBuilder(getLinuxSandboxPath(), commandArguments)
-            .build();
+        LinuxSandboxCommandLineBuilder.commandLineBuilder(getLinuxSandboxPath())
+            .buildForCommand(commandArguments);
 
     Command command = new Command(fullCommandLine.toArray(new String[0]));
     CommandResult commandResult = command.execute();
@@ -98,9 +98,9 @@
     Path statisticsFilePath = outputDir.getRelative("stats.out");
 
     List<String> fullCommandLine =
-        LinuxSandboxCommandLineBuilder.commandLineBuilder(getLinuxSandboxPath(), commandArguments)
+        LinuxSandboxCommandLineBuilder.commandLineBuilder(getLinuxSandboxPath())
             .setStatisticsPath(statisticsFilePath)
-            .build();
+            .buildForCommand(commandArguments);
 
     ExecutionStatisticsTestUtil.executeCommandAndCheckStatisticsAboutCpuTimeSpent(
         userTimeToSpend, systemTimeToSpend, fullCommandLine, statisticsFilePath);
diff --git a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java
index 54955ff..7336879 100644
--- a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java
+++ b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java
@@ -383,8 +383,8 @@
     CommandResult cmdResult = null;
     Command cmd =
         new Command(
-            LinuxSandboxCommandLineBuilder.commandLineBuilder(sandboxPath, ImmutableList.of("true"))
-                .build()
+            LinuxSandboxCommandLineBuilder.commandLineBuilder(sandboxPath)
+                .buildForCommand(ImmutableList.of("true"))
                 .toArray(new String[0]),
             ImmutableMap.of(),
             sandboxPath.getParentDirectory().getPathFile());