// Copyright 2017 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.exec.local;

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.io.ByteStreams;
import com.google.devtools.build.lib.actions.ActionInput;
import com.google.devtools.build.lib.actions.ActionInputFileCache;
import com.google.devtools.build.lib.actions.ActionInputHelper;
import com.google.devtools.build.lib.actions.Artifact.ArtifactExpander;
import com.google.devtools.build.lib.actions.ExecutionRequirements;
import com.google.devtools.build.lib.actions.ResourceManager;
import com.google.devtools.build.lib.actions.ResourceSet;
import com.google.devtools.build.lib.actions.Spawn;
import com.google.devtools.build.lib.exec.SpawnResult;
import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus;
import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionPolicy;
import com.google.devtools.build.lib.exec.util.SpawnBuilder;
import com.google.devtools.build.lib.shell.JavaSubprocessFactory;
import com.google.devtools.build.lib.shell.Subprocess;
import com.google.devtools.build.lib.shell.SubprocessBuilder;
import com.google.devtools.build.lib.util.NetUtil;
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.FileSystemUtils;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
import com.google.devtools.common.options.Options;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.logging.Filter;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.ArgumentCaptor;

/**
 * Unit tests for {@link LocalSpawnRunner}.
 */
@RunWith(JUnit4.class)
public class LocalSpawnRunnerTest {
  private static final boolean USE_WRAPPER = true;
  private static final boolean NO_WRAPPER = false;

  private static class FinishedSubprocess implements Subprocess {
    private final int exitCode;

    public FinishedSubprocess(int exitCode) {
      this.exitCode = exitCode;
    }

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

    @Override
    public int exitValue() {
      return exitCode;
    }

    @Override
    public boolean finished() {
      return true;
    }

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

    @Override
    public void waitFor() throws InterruptedException {
      // Do nothing.
    }

    @Override
    public OutputStream getOutputStream() {
      return ByteStreams.nullOutputStream();
    }

    @Override
    public InputStream getInputStream() {
      return new ByteArrayInputStream(new byte[0]);
    }

    @Override
    public InputStream getErrorStream() {
      return new ByteArrayInputStream(new byte[0]);
    }

    @Override
    public void close() {
      // Do nothing.
    }
  }

  private static final Spawn SIMPLE_SPAWN =
      new SpawnBuilder("/bin/echo", "Hi!").withEnvironment("VARIABLE", "value").build();

  private static final class SubprocessInterceptor implements Subprocess.Factory {
    @Override
    public Subprocess create(SubprocessBuilder params) throws IOException {
      throw new UnsupportedOperationException();
    }
  }

  private final class SpawnExecutionPolicyForTesting implements SpawnExecutionPolicy {
    private final List<ProgressStatus> reportedStatus = new ArrayList<>();
    private final TreeMap<PathFragment, ActionInput> inputMapping = new TreeMap<>();

    private long timeoutMillis;
    private final List<Iterable<ActionInput>> prefetched = new ArrayList<>();
    private boolean lockOutputFilesCalled;

    @Override
    public int getId() {
      return 0;
    }

    @Override
    public void prefetchInputs(Iterable<ActionInput> inputs) throws IOException {
      prefetched.add(Preconditions.checkNotNull(inputs));
    }

    @Override
    public void lockOutputFiles() throws InterruptedException {
      lockOutputFilesCalled = true;
    }

    @Override
    public ActionInputFileCache getActionInputFileCache() {
      return mockFileCache;
    }

    @Override
    public ArtifactExpander getArtifactExpander() {
      throw new UnsupportedOperationException();
    }

    @Override
    public long getTimeoutMillis() {
      return timeoutMillis;
    }

    @Override
    public FileOutErr getFileOutErr() {
      return outErr;
    }

    @Override
    public SortedMap<PathFragment, ActionInput> getInputMapping() {
      return inputMapping;
    }

    @Override
    public void report(ProgressStatus state, String name) {
      reportedStatus.add(state);
    }
  }

  private FileSystem fs;
  private final ActionInputFileCache mockFileCache = mock(ActionInputFileCache.class);
  private final ResourceManager resourceManager = ResourceManager.instanceForTestingOnly();

  private Logger logger;
  private FileOutErr outErr;

  private final SpawnExecutionPolicyForTesting policy = new SpawnExecutionPolicyForTesting();

  @Before
  public final void suppressLogging() {
    logger = Logger.getLogger(LocalSpawnRunner.class.getName());
    logger.setFilter(new Filter() {
      @Override
      public boolean isLoggable(LogRecord record) {
        return false;
      }
    });
  }

  @Before
  public final void setup() throws Exception  {
    fs = new InMemoryFileSystem();
    // Prevent any subprocess execution at all.
    SubprocessBuilder.setSubprocessFactory(new SubprocessInterceptor());
    resourceManager.setAvailableResources(
        ResourceSet.create(/*memoryMb=*/1, /*cpuUsage=*/1, /*ioUsage=*/1, /*localTestCount=*/1));
  }

  @After
  public final void tearDown() {
    SubprocessBuilder.setSubprocessFactory(JavaSubprocessFactory.INSTANCE);
  }

  @Test
  public void vanillaZeroExit() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    ArgumentCaptor<SubprocessBuilder> captor = ArgumentCaptor.forClass(SubprocessBuilder.class);
    when(factory.create(captor.capture())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    options.localSigkillGraceSeconds = 456;
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    policy.timeoutMillis = 123 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    SpawnResult result = runner.exec(SIMPLE_SPAWN, policy);
    verify(factory).create(any(SubprocessBuilder.class));
    assertThat(result.status()).isEqualTo(SpawnResult.Status.SUCCESS);
    assertThat(result.exitCode()).isEqualTo(0);
    assertThat(result.setupSuccess()).isTrue();
    assertThat(result.getExecutorHostName()).isEqualTo(NetUtil.findShortHostName());

    assertThat(captor.getValue().getArgv())
        .containsExactlyElementsIn(
            ImmutableList.of(
                "/execroot/_bin/process-wrapper",
                "--timeout=123",
                "--kill_delay=456",
                "--stdout=/out/stdout",
                "--stderr=/out/stderr",
                "/bin/echo",
                "Hi!"));
    assertThat(captor.getValue().getEnv()).containsExactly("VARIABLE", "value");
    assertThat(captor.getValue().getTimeoutMillis()).isEqualTo(-1);

    assertThat(policy.lockOutputFilesCalled).isTrue();
    assertThat(policy.reportedStatus)
        .containsExactly(ProgressStatus.SCHEDULING, ProgressStatus.EXECUTING).inOrder();
  }

  @Test
  public void noProcessWrapper() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    ArgumentCaptor<SubprocessBuilder> captor = ArgumentCaptor.forClass(SubprocessBuilder.class);
    when(factory.create(captor.capture())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    options.localSigkillGraceSeconds = 456;
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, NO_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    policy.timeoutMillis = 123 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    SpawnResult result = runner.exec(SIMPLE_SPAWN, policy);
    verify(factory).create(any());
    assertThat(result.status()).isEqualTo(SpawnResult.Status.SUCCESS);
    assertThat(result.exitCode()).isEqualTo(0);
    assertThat(result.setupSuccess()).isTrue();
    assertThat(result.getExecutorHostName()).isEqualTo(NetUtil.findShortHostName());

    assertThat(captor.getValue().getArgv())
        .containsExactlyElementsIn(ImmutableList.of("/bin/echo", "Hi!"));
    assertThat(captor.getValue().getEnv()).containsExactly("VARIABLE", "value");
    // Without the process wrapper, we use the Command API to enforce the timeout.
    assertThat(captor.getValue().getTimeoutMillis()).isEqualTo(policy.timeoutMillis);

    assertThat(policy.lockOutputFilesCalled).isTrue();
  }

  @Test
  public void nonZeroExit() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    ArgumentCaptor<SubprocessBuilder> captor = ArgumentCaptor.forClass(SubprocessBuilder.class);
    when(factory.create(captor.capture())).thenReturn(new FinishedSubprocess(3));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    SpawnResult result = runner.exec(SIMPLE_SPAWN, policy);
    verify(factory).create(any(SubprocessBuilder.class));
    assertThat(result.status()).isEqualTo(SpawnResult.Status.SUCCESS);
    assertThat(result.exitCode()).isEqualTo(3);
    assertThat(result.setupSuccess()).isTrue();
    assertThat(result.getExecutorHostName()).isEqualTo(NetUtil.findShortHostName());

    assertThat(captor.getValue().getArgv())
        .containsExactlyElementsIn(
            ImmutableList.of(
                // process-wrapper timeout grace_time stdout stderr
                "/execroot/_bin/process-wrapper",
                "--timeout=0",
                "--kill_delay=15",
                "--stdout=/out/stdout",
                "--stderr=/out/stderr",
                "/bin/echo",
                "Hi!"));
    assertThat(captor.getValue().getEnv()).containsExactly("VARIABLE", "value");

    assertThat(policy.lockOutputFilesCalled).isTrue();
  }

  @Test
  public void processStartupThrows() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    ArgumentCaptor<SubprocessBuilder> captor = ArgumentCaptor.forClass(SubprocessBuilder.class);
    when(factory.create(captor.capture())).thenThrow(new IOException("I'm sorry, Dave"));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    assertThat(fs.getPath("/out").createDirectory()).isTrue();
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    SpawnResult result = runner.exec(SIMPLE_SPAWN, policy);
    verify(factory).create(any(SubprocessBuilder.class));
    assertThat(result.status()).isEqualTo(SpawnResult.Status.EXECUTION_FAILED);
    assertThat(result.exitCode()).isEqualTo(-1);
    assertThat(result.setupSuccess()).isFalse();
    assertThat(result.getWallTimeMillis()).isEqualTo(0);
    assertThat(result.getExecutorHostName()).isEqualTo(NetUtil.findShortHostName());

    assertThat(FileSystemUtils.readContent(fs.getPath("/out/stderr"), StandardCharsets.UTF_8))
        .isEqualTo("Action failed to execute: java.io.IOException: I'm sorry, Dave\n");

    assertThat(policy.lockOutputFilesCalled).isTrue();
  }

  @Test
  public void disallowLocalExecution() throws Exception {
    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    options.allowedLocalAction = Pattern.compile("none");
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    outErr = new FileOutErr();
    SpawnResult reply = runner.exec(SIMPLE_SPAWN, policy);
    assertThat(reply.status()).isEqualTo(SpawnResult.Status.LOCAL_ACTION_NOT_ALLOWED);
    assertThat(reply.exitCode()).isEqualTo(-1);
    assertThat(reply.setupSuccess()).isFalse();
    assertThat(reply.getWallTimeMillis()).isEqualTo(0);
    assertThat(reply.getExecutorHostName()).isEqualTo(NetUtil.findShortHostName());

    // TODO(ulfjack): Maybe we should only lock after checking?
    assertThat(policy.lockOutputFilesCalled).isTrue();
  }

  @Test
  public void interruptedException() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    ArgumentCaptor<SubprocessBuilder> captor = ArgumentCaptor.forClass(SubprocessBuilder.class);
    when(factory.create(captor.capture())).thenReturn(new FinishedSubprocess(3) {
      private boolean destroyed;

      @Override
      public boolean destroy() {
        destroyed = true;
        return true;
      }

      @Override
      public void waitFor() throws InterruptedException {
        if (!destroyed) {
          throw new InterruptedException();
        }
      }
    });
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    try {
      runner.exec(SIMPLE_SPAWN, policy);
      fail();
    } catch (InterruptedException expected) {
      // Clear the interrupted status or subsequent tests in the same process will fail.
      Thread.interrupted();
    }
    assertThat(policy.lockOutputFilesCalled).isTrue();
  }

  @Test
  public void checkPrefetchCalled() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    when(factory.create(any())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    policy.timeoutMillis = 123 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    runner.exec(SIMPLE_SPAWN, policy);
    assertThat(policy.prefetched).isNotEmpty();
  }

  @Test
  public void checkNoPrefetchCalled() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    when(factory.create(any())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    policy.timeoutMillis = 123 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));

    Spawn spawn = new SpawnBuilder("/bin/echo", "Hi!")
        .withExecutionInfo(ExecutionRequirements.DISABLE_LOCAL_PREFETCH, "").build();
    runner.exec(spawn, policy);
    assertThat(policy.prefetched).isEmpty();
  }

  /**
   * Regression test: the SpawnInputExpander can return null values for empty files, but the
   * ActionInputPrefetcher expects no null values.
   */
  @Test
  public void checkPrefetchCalledNonNull() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    when(factory.create(any())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", LocalEnvProvider.UNMODIFIED);

    policy.inputMapping.put(PathFragment.create("relative/path"), null);
    policy.inputMapping.put(
        PathFragment.create("another/relative/path"), ActionInputHelper.fromPath("/absolute/path"));
    policy.timeoutMillis = 123 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    runner.exec(SIMPLE_SPAWN, policy);
    assertThat(policy.prefetched).hasSize(1);
    Iterable<ActionInput> prefetched = policy.prefetched.get(0);
    assertThat(prefetched).doesNotContain(null);
    assertThat(prefetched).containsExactly(ActionInputHelper.fromPath("/absolute/path"));
  }

  @Test
  public void checkLocalEnvProviderCalled() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    when(factory.create(any())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);
    LocalEnvProvider localEnvProvider = mock(LocalEnvProvider.class);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.LINUX,
        "product-name", localEnvProvider);

    policy.timeoutMillis = 123 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));

    runner.exec(SIMPLE_SPAWN, policy);
    verify(localEnvProvider)
        .rewriteLocalEnv(any(), eq(fs.getPath("/execroot")), eq("product-name"));
  }

  @Test
  public void useCorrectExtensionOnWindows() throws Exception {
    Subprocess.Factory factory = mock(Subprocess.Factory.class);
    ArgumentCaptor<SubprocessBuilder> captor = ArgumentCaptor.forClass(SubprocessBuilder.class);
    when(factory.create(captor.capture())).thenReturn(new FinishedSubprocess(0));
    SubprocessBuilder.setSubprocessFactory(factory);

    LocalExecutionOptions options = Options.getDefaults(LocalExecutionOptions.class);
    options.localSigkillGraceSeconds = 654;
    LocalSpawnRunner runner = new LocalSpawnRunner(
        fs.getPath("/execroot"), options, resourceManager, USE_WRAPPER, OS.WINDOWS,
        "product-name", LocalEnvProvider.UNMODIFIED);

    policy.timeoutMillis = 321 * 1000L;
    outErr = new FileOutErr(fs.getPath("/out/stdout"), fs.getPath("/out/stderr"));
    SpawnResult result = runner.exec(SIMPLE_SPAWN, policy);
    verify(factory).create(any(SubprocessBuilder.class));
    assertThat(result.status()).isEqualTo(SpawnResult.Status.SUCCESS);

    assertThat(captor.getValue().getArgv())
        .containsExactlyElementsIn(
            ImmutableList.of(
                // process-wrapper timeout grace_time stdout stderr
                "/execroot/_bin/process-wrapper.exe",
                "--timeout=321",
                "--kill_delay=654",
                "--stdout=/out/stdout",
                "--stderr=/out/stderr",
                "/bin/echo",
                "Hi!"));
  }
}
