blob: 490b36f23bc30084adcac86bb1539813cae95f02 [file] [log] [blame]
/*
* Copyright 2012-present Facebook, Inc.
*
* 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.facebook.buck.shell;
import com.facebook.buck.event.ConsoleEvent;
import com.facebook.buck.log.Logger;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.util.Escaper;
import com.facebook.buck.util.ImmutableProcessExecutorParams;
import com.facebook.buck.util.ProcessExecutor;
import com.facebook.buck.util.ProcessExecutor.Option;
import com.facebook.buck.util.ProcessExecutorParams;
import com.facebook.buck.util.Verbosity;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Map.Entry;
import javax.annotation.Nullable;
public abstract class ShellStep implements Step {
private static final Logger LOG = Logger.get(ShellStep.class);
/** Defined lazily by {@link #getShellCommand(com.facebook.buck.step.ExecutionContext)}. */
@Nullable
private ImmutableList<String> shellCommandArgs;
/** If specified, working directory will be different from project root. **/
@Nullable
protected final File workingDirectory;
/**
* This is set if {@link #shouldPrintStdout(Verbosity)} returns {@code true} when the command is
* executed.
*/
private Optional<String> stdout;
/**
* This is set if {@link #shouldPrintStderr(Verbosity)} returns {@code true} when the command is
* executed.
*/
private Optional<String> stderr;
private long startTime = 0L;
private long endTime = 0L;
protected ShellStep() {
this(/* workingDirectory */ null);
}
protected ShellStep(@Nullable File workingDirectory) {
this.workingDirectory = workingDirectory;
this.stdout = Optional.absent();
this.stderr = Optional.absent();
}
/**
* Get the working directory for this command.
* @return working directory specified on construction
* ({@code null} if project directory will be used).
*/
@Nullable
@VisibleForTesting
public File getWorkingDirectory() {
return workingDirectory;
}
@Override
public int execute(ExecutionContext context) throws InterruptedException {
// Kick off a Process in which this ShellCommand will be run.
ImmutableProcessExecutorParams.Builder builder = ImmutableProcessExecutorParams.builder();
builder.setCommand(getShellCommand(context));
Map<String, String> environment = Maps.newHashMap();
setProcessEnvironment(context, environment);
builder.setEnvironment(environment);
if (workingDirectory != null) {
builder.setDirectory(workingDirectory);
} else {
builder.setDirectory(context.getProjectDirectoryRoot().toAbsolutePath().toFile());
}
Optional<String> stdin = getStdin(context);
if (stdin.isPresent()) {
builder.setRedirectInput(ProcessBuilder.Redirect.PIPE);
}
int exitCode;
try {
startTime = System.currentTimeMillis();
exitCode = launchAndInteractWithProcess(context, builder.build());
} catch (IOException e) {
e.printStackTrace(context.getStdErr());
exitCode = 1;
}
endTime = System.currentTimeMillis();
return exitCode;
}
@VisibleForTesting
void setProcessEnvironment(ExecutionContext context, Map<String, String> environment) {
// Replace environment with client environment.
environment.clear();
environment.putAll(context.getEnvironment());
// Add extra environment variables for step, if appropriate.
if (!getEnvironmentVariables(context).isEmpty()) {
environment.putAll(getEnvironmentVariables(context));
}
}
/**
* @return the exit code interpreted from the {@code result}.
*/
@SuppressWarnings("unused")
protected int getExitCodeFromResult(ExecutionContext context, ProcessExecutor.Result result) {
return result.getExitCode();
}
@VisibleForTesting
int launchAndInteractWithProcess(ExecutionContext context, ProcessExecutorParams params)
throws InterruptedException, IOException {
ImmutableSet.Builder<Option> options = ImmutableSet.builder();
addOptions(context, options);
ProcessExecutor executor = context.getProcessExecutor();
ProcessExecutor.Result result = executor.launchAndExecute(
params,
options.build(),
getStdin(context),
getTimeout());
stdout = result.getStdout();
stderr = result.getStderr();
Verbosity verbosity = context.getVerbosity();
if (stdout.isPresent() && !stdout.get().isEmpty() && shouldPrintStdout(verbosity)) {
context.postEvent(ConsoleEvent.info("%s", stdout.get()));
}
if (stderr.isPresent() && !stderr.get().isEmpty() && shouldPrintStderr(verbosity)) {
context.postEvent(ConsoleEvent.warning("%s", stderr.get()));
}
return getExitCodeFromResult(context, result);
}
protected void addOptions(
ExecutionContext context,
ImmutableSet.Builder<Option> options) {
if (shouldFlushStdOutErrAsProgressIsMade(context.getVerbosity())) {
options.add(Option.PRINT_STD_OUT);
options.add(Option.PRINT_STD_ERR);
}
if (context.getVerbosity() == Verbosity.SILENT) {
options.add(Option.IS_SILENT);
}
}
public long getDuration() {
Preconditions.checkState(startTime > 0);
Preconditions.checkState(endTime > 0);
return endTime - startTime;
}
/**
* This method is idempotent.
* @return the shell command arguments
*/
public final ImmutableList<String> getShellCommand(ExecutionContext context) {
if (shellCommandArgs == null) {
shellCommandArgs = getShellCommandInternal(context);
LOG.debug("Command: %s", Joiner.on(" ").join(shellCommandArgs));
}
return shellCommandArgs;
}
@SuppressWarnings("unused")
protected Optional<String> getStdin(ExecutionContext context) {
return Optional.absent();
}
/**
* Implementations of this method should not have any observable side-effects.
*/
@VisibleForTesting
protected abstract ImmutableList<String> getShellCommandInternal(ExecutionContext context);
@Override
public final String getDescription(ExecutionContext context) {
// Get environment variables for this command as VAR1=val1 VAR2=val2... etc., with values
// quoted as necessary.
Iterable<String> env = Iterables.transform(getEnvironmentVariables(context).entrySet(),
new Function<Entry<String, String>, String>() {
@Override
public String apply(Entry<String, String> e) {
return String.format("%s=%s", e.getKey(), Escaper.escapeAsBashString(e.getValue()));
}
});
// Quote the arguments to the shell command as needed (this applies to $0 as well
// e.g. if we run '/path/a b.sh' quoting is needed).
Iterable<String> cmd = Iterables.transform(getShellCommand(context), Escaper.BASH_ESCAPER);
String shellCommand = Joiner.on(" ").join(Iterables.concat(env, cmd));
if (getWorkingDirectory() == null) {
return shellCommand;
} else {
// If the ShellCommand has a specific working directory, set through ProcessBuilder, then
// this is what the user might type in a shell to get the same behavior. The (...) syntax
// introduces a subshell in which the command is only executed if cd was successful.
return String.format("(cd %s && %s)",
Escaper.escapeAsBashString(Preconditions.checkNotNull(workingDirectory).getPath()),
shellCommand);
}
}
/**
* Returns the environment variables to include when running this {@link ShellStep}.
* <p>
* By default, this method returns an empty map.
* @param context that may be useful when determining environment variables to include.
*/
public ImmutableMap<String, String> getEnvironmentVariables(ExecutionContext context) {
return ImmutableMap.of();
}
/**
* @param verbosity is provided in case that affects what should be printed.
* @return whether the stdout of the shell command, when executed, should be printed to the stderr
* of the specified {@link ExecutionContext}. If {@code false}, stdout will only be printed on
* error and only if verbosity is set to standard information.
*/
protected boolean shouldPrintStdout(Verbosity verbosity) {
return false;
}
/**
* @return the stdout of this ShellCommand or throws an exception if the stdout was not recorded
*/
public final String getStdout() {
Preconditions.checkState(
this.stdout.isPresent(),
"stdout was not set: shouldPrintStdout() must return false and execute() must " +
"have been invoked");
return this.stdout.get();
}
/**
* @return whether the stderr of the shell command, when executed, should be printed to the stderr
* of the specified {@link ExecutionContext}. If {@code false}, stderr will only be printed on
* error and only if verbosity is set to standard information.
*/
protected boolean shouldPrintStderr(Verbosity verbosity) {
return verbosity.shouldPrintOutput();
}
/**
* @return the stderr of this ShellCommand or throws an exception if the stderr was not recorded
*/
public final String getStderr() {
Preconditions.checkState(
this.stderr.isPresent(),
"stderr was not set: shouldPrintStdErr() must return false and execute() must " +
"have been invoked");
return this.stderr.get();
}
/**
* By default, the output written to stdout and stderr will be buffered into individual
* {@link ByteArrayOutputStream}s and then converted into strings for easier consumption. This
* means that output from both streams that would normally be interleaved will now be displayed
* separately.
* <p>
* To disable this behavior and print to stdout and stderr directly, this method should be
* overridden to return {@code true}.
*/
@SuppressWarnings("unused")
protected boolean shouldFlushStdOutErrAsProgressIsMade(Verbosity verbosity) {
return false;
}
/**
* @return an optional timeout to apply to the step.
*/
protected Optional<Long> getTimeout() {
return Optional.absent();
}
}