// Copyright 2016 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 static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
import com.google.devtools.build.lib.actions.ExecException;
import com.google.devtools.build.lib.actions.ForbiddenActionInputException;
import com.google.devtools.build.lib.actions.ResourceManager;
import com.google.devtools.build.lib.actions.ResourceManager.ResourceHandle;
import com.google.devtools.build.lib.actions.ResourceManager.ResourcePriority;
import com.google.devtools.build.lib.actions.Spawn;
import com.google.devtools.build.lib.actions.SpawnResult;
import com.google.devtools.build.lib.actions.SpawnResult.Status;
import com.google.devtools.build.lib.actions.Spawns;
import com.google.devtools.build.lib.actions.UserExecException;
import com.google.devtools.build.lib.exec.BinTools;
import com.google.devtools.build.lib.exec.ExecutionOptions;
import com.google.devtools.build.lib.exec.SpawnExecutingEvent;
import com.google.devtools.build.lib.exec.SpawnRunner;
import com.google.devtools.build.lib.exec.SpawnSchedulingEvent;
import com.google.devtools.build.lib.exec.TreeDeleter;
import com.google.devtools.build.lib.profiler.Profiler;
import com.google.devtools.build.lib.profiler.SilentCloseable;
import com.google.devtools.build.lib.runtime.CommandEnvironment;
import com.google.devtools.build.lib.server.FailureDetails;
import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
import com.google.devtools.build.lib.server.FailureDetails.Sandbox;
import com.google.devtools.build.lib.server.FailureDetails.Sandbox.Code;
import com.google.devtools.build.lib.shell.ExecutionStatistics;
import com.google.devtools.build.lib.shell.Subprocess;
import com.google.devtools.build.lib.shell.SubprocessBuilder;
import com.google.devtools.build.lib.shell.TerminationStatus;
import com.google.devtools.build.lib.util.CommandFailureUtils;
import com.google.devtools.build.lib.util.OS;
import com.google.devtools.build.lib.util.io.FileOutErr;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.Path;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;

/** Abstract common ancestor for sandbox spawn runners implementing the common parts. */
abstract class AbstractSandboxSpawnRunner implements SpawnRunner {
  private static final int LOCAL_EXEC_ERROR = -1;

  private static final String SANDBOX_DEBUG_SUGGESTION =
      "\n\nUse --sandbox_debug to see verbose messages from the sandbox "
          + "and retain the sandbox build root for debugging";

  private final SandboxOptions sandboxOptions;
  private final boolean verboseFailures;
  private final ImmutableSet<Path> inaccessiblePaths;
  protected final BinTools binTools;
  private final Path execRoot;
  private final ResourceManager resourceManager;

  public AbstractSandboxSpawnRunner(CommandEnvironment cmdEnv) {
    this.sandboxOptions = cmdEnv.getOptions().getOptions(SandboxOptions.class);
    this.verboseFailures = cmdEnv.getOptions().getOptions(ExecutionOptions.class).verboseFailures;
    this.inaccessiblePaths =
        sandboxOptions.getInaccessiblePaths(cmdEnv.getRuntime().getFileSystem());
    this.binTools = cmdEnv.getBlazeWorkspace().getBinTools();
    this.execRoot = cmdEnv.getExecRoot();
    this.resourceManager = cmdEnv.getLocalResourceManager();
  }

  @Override
  public final SpawnResult exec(Spawn spawn, SpawnExecutionContext context)
      throws ExecException, InterruptedException {
    ActionExecutionMetadata owner = spawn.getResourceOwner();
    context.report(SpawnSchedulingEvent.create(getName()));

    try {
      try (SilentCloseable c = Profiler.instance().profile("context.prefetchInputs")) {
        context.prefetchInputsAndWait();
      }

      try (ResourceHandle ignored =
          resourceManager.acquireResources(
              owner,
              spawn.getLocalResources(),
              context.speculating()
                  ? ResourcePriority.DYNAMIC_STANDALONE
                  : ResourcePriority.LOCAL)) {
        context.report(SpawnExecutingEvent.create(getName()));
        SandboxedSpawn sandbox = prepareSpawn(spawn, context);
        return runSpawn(spawn, sandbox, context);
      }
    } catch (IOException e) {
      FailureDetail failureDetail =
          createFailureDetail(
              "I/O exception during sandboxed execution", Code.EXECUTION_IO_EXCEPTION);
      throw new UserExecException(e, failureDetail);
    } catch (ForbiddenActionInputException e) {
      FailureDetail failureDetail =
          createFailureDetail(
              "Forbidden input found during sandboxed execution", Code.FORBIDDEN_INPUT);
      throw new UserExecException(e, failureDetail);
    }
  }

  @Override
  public boolean canExec(Spawn spawn) {
    return Spawns.mayBeSandboxed(spawn);
  }

  @Override
  public boolean handlesCaching() {
    return false;
  }

  protected abstract SandboxedSpawn prepareSpawn(Spawn spawn, SpawnExecutionContext context)
      throws IOException, ExecException, InterruptedException, ForbiddenActionInputException;

  private SpawnResult runSpawn(
      Spawn originalSpawn, SandboxedSpawn sandbox, SpawnExecutionContext context)
      throws IOException, ForbiddenActionInputException, InterruptedException {
    try {
      try (SilentCloseable c = Profiler.instance().profile("sandbox.createFileSystem")) {
        sandbox.createFileSystem();
      }
      FileOutErr outErr = context.getFileOutErr();

      SpawnResult result;
      try (SilentCloseable c = Profiler.instance().profile("subprocess.run")) {
        result = run(originalSpawn, sandbox, context.getTimeout(), outErr);
      }
      try (SilentCloseable c = Profiler.instance().profile("sandbox.verifyPostCondition")) {
        verifyPostCondition(originalSpawn, sandbox, context);
      }

      context.lockOutputFiles(
          result.exitCode(),
          result.failureDetail() != null ? result.failureDetail().getMessage() : "",
          outErr);
      try (SilentCloseable c = Profiler.instance().profile("sandbox.copyOutputs")) {
        // We copy the outputs even when the command failed.
        sandbox.copyOutputs(execRoot);
      } catch (IOException e) {
        throw new IOException("Could not move output artifacts from sandboxed execution", e);
      }
      return result;
    } finally {
      if (!sandboxOptions.sandboxDebug) {
        try (SilentCloseable c = Profiler.instance().profile("sandbox.delete")) {
          sandbox.delete();
        }
      }
    }
  }
  /** Override this method if you need to run a post condition after the action has executed */
  public void verifyPostCondition(
      Spawn originalSpawn, SandboxedSpawn sandbox, SpawnExecutionContext context)
      throws IOException, ForbiddenActionInputException {}

  private String makeFailureMessage(Spawn originalSpawn, SandboxedSpawn sandbox) {
    if (sandboxOptions.sandboxDebug) {
      return CommandFailureUtils.describeCommandFailure(
          true, sandbox.getSandboxExecRoot().getPathString(), sandbox);
    } else {
      return CommandFailureUtils.describeCommandFailure(
              verboseFailures, sandbox.getSandboxExecRoot().getPathString(), originalSpawn)
          + SANDBOX_DEBUG_SUGGESTION;
    }
  }

  private final SpawnResult run(
      Spawn originalSpawn, SandboxedSpawn sandbox, Duration timeout, FileOutErr outErr)
      throws IOException, InterruptedException {
    SubprocessBuilder subprocessBuilder = new SubprocessBuilder();
    subprocessBuilder.setWorkingDirectory(sandbox.getSandboxExecRoot().getPathFile());
    subprocessBuilder.setStdout(outErr.getOutputPath().getPathFile());
    subprocessBuilder.setStderr(outErr.getErrorPath().getPathFile());
    subprocessBuilder.setEnv(sandbox.getEnvironment());
    subprocessBuilder.setArgv(ImmutableList.copyOf(sandbox.getArguments()));
    boolean useSubprocessTimeout = sandbox.useSubprocessTimeout();
    if (useSubprocessTimeout) {
      subprocessBuilder.setTimeoutMillis(timeout.toMillis());
    }
    Instant startTime = Instant.now();
    TerminationStatus terminationStatus;
    try {
      Subprocess subprocess = subprocessBuilder.start();
      subprocess.getOutputStream().close();
      try {
        subprocess.waitFor();
        terminationStatus = new TerminationStatus(subprocess.exitValue(), subprocess.timedout());
      } catch (InterruptedException e) {
        subprocess.destroyAndWait();
        throw e;
      }
    } catch (IOException e) {
      String msg = e.getMessage() == null ? e.getClass().getName() : e.getMessage();
      outErr
          .getErrorStream()
          .write(("Action failed to execute: java.io.IOException: " + msg + "\n").getBytes(UTF_8));
      outErr.getErrorStream().flush();
      String message = makeFailureMessage(originalSpawn, sandbox);
      return new SpawnResult.Builder()
          .setRunnerName(getName())
          .setStatus(Status.EXECUTION_FAILED)
          .setExitCode(LOCAL_EXEC_ERROR)
          .setFailureMessage(message)
          .setFailureDetail(createFailureDetail(message, Code.SUBPROCESS_START_FAILED))
          .build();
    }

    // TODO(b/62588075): Calculate wall time inside Subprocess instead?
    Duration wallTime = Duration.between(startTime, Instant.now());
    boolean wasTimeout =
        (useSubprocessTimeout && terminationStatus.timedOut())
            || (!useSubprocessTimeout && wasTimeout(timeout, wallTime));

    int exitCode;
    Status status;
    String failureMessage;
    FailureDetail failureDetail;
    if (wasTimeout) {
      exitCode = SpawnResult.POSIX_TIMEOUT_EXIT_CODE;
      status = Status.TIMEOUT;
      failureMessage = makeFailureMessage(originalSpawn, sandbox);
      failureDetail =
          FailureDetail.newBuilder()
              .setMessage(failureMessage)
              .setSpawn(
                  FailureDetails.Spawn.newBuilder().setCode(FailureDetails.Spawn.Code.TIMEOUT))
              .build();
    } else {
      exitCode = terminationStatus.getRawExitCode();
      if (exitCode == 0) {
        status = Status.SUCCESS;
        failureMessage = "";
        failureDetail = null;
      } else {
        status = Status.NON_ZERO_EXIT;
        failureMessage = makeFailureMessage(originalSpawn, sandbox);
        failureDetail =
            FailureDetail.newBuilder()
                .setMessage(failureMessage)
                .setSpawn(
                    FailureDetails.Spawn.newBuilder()
                        .setCode(FailureDetails.Spawn.Code.NON_ZERO_EXIT)
                        .setSpawnExitCode(exitCode))
                .build();
      }
    }

    SpawnResult.Builder spawnResultBuilder =
        new SpawnResult.Builder()
            .setRunnerName(getName())
            .setStatus(status)
            .setExitCode(exitCode)
            .setStartTime(startTime)
            .setWallTime(wallTime)
            .setFailureMessage(failureMessage);

    if (failureDetail != null) {
      spawnResultBuilder.setFailureDetail(failureDetail);
    }

    Path statisticsPath = sandbox.getStatisticsPath();
    if (statisticsPath != null) {
      ExecutionStatistics.getResourceUsage(statisticsPath)
          .ifPresent(
              resourceUsage -> {
                spawnResultBuilder.setUserTime(resourceUsage.getUserExecutionTime());
                spawnResultBuilder.setSystemTime(resourceUsage.getSystemExecutionTime());
                spawnResultBuilder.setNumBlockOutputOperations(
                    resourceUsage.getBlockOutputOperations());
                spawnResultBuilder.setNumBlockInputOperations(
                    resourceUsage.getBlockInputOperations());
                spawnResultBuilder.setNumInvoluntaryContextSwitches(
                    resourceUsage.getInvoluntaryContextSwitches());
                // The memory usage of the largest child process. For Darwin maxrss returns size in
                // bytes.
                if (OS.getCurrent() == OS.DARWIN) {
                  spawnResultBuilder.setMemoryInKb(
                      resourceUsage.getMaximumResidentSetSize() / 1000);
                } else {
                  spawnResultBuilder.setMemoryInKb(resourceUsage.getMaximumResidentSetSize());
                }
              });
    }

    return spawnResultBuilder.build();
  }

  private boolean wasTimeout(Duration timeout, Duration wallTime) {
    return !timeout.isZero() && wallTime.compareTo(timeout) > 0;
  }

  /**
   * Gets the list of directories that the spawn will assume to be writable.
   *
   * @throws IOException because we might resolve symlinks, which throws {@link IOException}.
   */
  protected ImmutableSet<Path> getWritableDirs(Path sandboxExecRoot, Map<String, String> env)
      throws IOException {
    // We have to make the TEST_TMPDIR directory writable if it is specified.
    ImmutableSet.Builder<Path> writablePaths = ImmutableSet.builder();

    // On Windows, sandboxExecRoot is actually the main execroot. We will specify
    // exactly which output path is writable.
    if (OS.getCurrent() != OS.WINDOWS) {
      writablePaths.add(sandboxExecRoot);
    }

    String testTmpdir = env.get("TEST_TMPDIR");
    if (testTmpdir != null) {
      addWritablePath(
          sandboxExecRoot,
          writablePaths,
          testTmpdir,
          "Cannot resolve symlinks in TEST_TMPDIR because it doesn't exist: \"%s\"");
    }
    // As of 2019-07-08:
    // - every caller of `getWritableDirs` passes a LocalEnvProvider-processed environment as
    //   `env`, therefore `env` surely has an entry for TMPDIR on Unix and TEMP/TMP on Windows.
    if (OS.getCurrent() == OS.WINDOWS) {
      addWritablePath(
          sandboxExecRoot,
          writablePaths,
          Preconditions.checkNotNull(env.get("TEMP")),
          "Cannot resolve symlinks in TEMP because it doesn't exist: \"%s\"");
      addWritablePath(
          sandboxExecRoot,
          writablePaths,
          Preconditions.checkNotNull(env.get("TMP")),
          "Cannot resolve symlinks in TMP because it doesn't exist: \"%s\"");
    } else {
      addWritablePath(
          sandboxExecRoot,
          writablePaths,
          Preconditions.checkNotNull(env.get("TMPDIR")),
          "Cannot resolve symlinks in TMPDIR because it doesn't exist: \"%s\"");
    }

    FileSystem fileSystem = sandboxExecRoot.getFileSystem();
    for (String writablePath : sandboxOptions.sandboxWritablePath) {
      Path path = fileSystem.getPath(writablePath);
      writablePaths.add(path);
      // TODO(laszlocsomor): Remove if guard when path.resolveSymbolicLinks supports non-symlink
      // TODO(laszlocsomor): Figure out why OS.getCurrent() != OS.WINDOWS is required, and remove it
      if (OS.getCurrent() != OS.WINDOWS || path.isSymbolicLink()) {
        writablePaths.add(path.resolveSymbolicLinks());
      }
    }

    return writablePaths.build();
  }

  private void addWritablePath(
      Path sandboxExecRoot,
      ImmutableSet.Builder<Path> writablePaths,
      String pathString,
      String pathDoesNotExistErrorTemplate)
      throws IOException {
    Path path = sandboxExecRoot.getRelative(pathString);
    if (path.startsWith(sandboxExecRoot)) {
      // We add this path even though it is below sandboxExecRoot (and thus already writable as a
      // subpath) to take advantage of the side-effect that SymlinkedExecRoot also creates this
      // needed directory if it doesn't exist yet.
      writablePaths.add(path);
    } else if (path.exists()) {
      // If `path` itself is a symlink, then adding it to `writablePaths` would result in making
      // the symlink itself writable, not what it points to. Therefore we need to resolve symlinks
      // in `path`, however for that we need `path` to exist.
      //
      // TODO(laszlocsomor): Remove if guard when path.resolveSymbolicLinks supports non-symlink
      // TODO(laszlocsomor): Figure out why OS.getCurrent() != OS.WINDOWS is required, and remove it
      if (OS.getCurrent() != OS.WINDOWS || path.isSymbolicLink()) {
        writablePaths.add(path.resolveSymbolicLinks());
      } else {
        writablePaths.add(path);
      }
    } else {
      throw new IOException(String.format(pathDoesNotExistErrorTemplate, path.getPathString()));
    }
  }

  protected ImmutableSet<Path> getInaccessiblePaths() {
    return inaccessiblePaths;
  }

  protected SandboxOptions getSandboxOptions() {
    return sandboxOptions;
  }

  @Override
  public void cleanupSandboxBase(Path sandboxBase, TreeDeleter treeDeleter) throws IOException {
    Path root = sandboxBase.getChild(getName());
    if (root.exists()) {
      for (Path child : root.getDirectoryEntries()) {
        treeDeleter.deleteTree(child);
      }
    }
  }

  static FailureDetail createFailureDetail(String message, Code detailedCode) {
    return FailureDetail.newBuilder()
        .setMessage(message)
        .setSandbox(Sandbox.newBuilder().setCode(detailedCode))
        .build();
  }
}
