blob: 192dc60b4058dc63f02ccf6c2f283ff7d92a41d2 [file] [log] [blame]
// Copyright 2015 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.bazel.dash;
import com.google.common.collect.ImmutableList;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteStreams;
import com.google.devtools.build.lib.bazel.dash.DashProtos.BuildData;
import com.google.devtools.build.lib.bazel.dash.DashProtos.BuildData.CommandLine.Option;
import com.google.devtools.build.lib.bazel.dash.DashProtos.BuildData.EnvironmentVar;
import com.google.devtools.build.lib.bazel.dash.DashProtos.BuildData.Target.TestData;
import com.google.devtools.build.lib.bazel.dash.DashProtos.Log;
import com.google.devtools.build.lib.events.Event;
import com.google.devtools.build.lib.events.Reporter;
import com.google.devtools.build.lib.packages.Target;
import com.google.devtools.build.lib.pkgcache.TargetParsingCompleteEvent;
import com.google.devtools.build.lib.rules.test.TestResult;
import com.google.devtools.build.lib.runtime.BlazeModule;
import com.google.devtools.build.lib.runtime.Command;
import com.google.devtools.build.lib.runtime.CommandEnvironment;
import com.google.devtools.build.lib.runtime.CommandStartEvent;
import com.google.devtools.build.lib.runtime.GotOptionsEvent;
import com.google.devtools.common.options.OptionsBase;
import com.google.devtools.common.options.OptionsParser.UnparsedOptionValueDescription;
import com.google.devtools.common.options.OptionsProvider;
import com.google.protobuf.ByteString;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
/**
* Dashboard for a build.
*/
public class DashModule extends BlazeModule {
private static final int ONE_MB = 1024 * 1024;
private static final NoOpSender NO_OP_SENDER = new NoOpSender();
private static final String DASH_SECRET_HEADER = "bazel-dash-secret";
private final ExecutorService executorService;
private Sendable sender;
private CommandEnvironment env;
private BuildData optionsBuildData;
public DashModule() {
// Make sure sender != null before we hop on the event bus.
sender = NO_OP_SENDER;
executorService = Executors.newFixedThreadPool(5,
new ThreadFactory() {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = Executors.defaultThreadFactory().newThread(runnable);
thread.setDaemon(true);
return thread;
}
});
}
@Override
public void beforeCommand(Command command, CommandEnvironment env) {
this.env = env;
env.getEventBus().register(this);
}
@Override
public void afterCommand() {
this.sender = NO_OP_SENDER;
this.env = null;
this.optionsBuildData = null;
}
@Override
public Iterable<Class<? extends OptionsBase>> getCommandOptions(Command command) {
return "build".equals(command.name())
? ImmutableList.<Class<? extends OptionsBase>>of(DashOptions.class)
: ImmutableList.<Class<? extends OptionsBase>>of();
}
@Override
public void handleOptions(OptionsProvider optionsProvider) {
DashOptions options = optionsProvider.getOptions(DashOptions.class);
try {
sender = (options == null || !options.useDash)
? NO_OP_SENDER
: new Sender(options.url, options.secret, env, executorService);
} catch (SenderException e) {
env.getReporter().handle(e.toEvent());
sender = NO_OP_SENDER;
}
if (optionsBuildData != null) {
sender.send("options", optionsBuildData);
}
optionsBuildData = null;
}
@Subscribe
public void gotOptions(GotOptionsEvent event) {
BuildData.Builder builder = BuildData.newBuilder();
BuildData.CommandLine.Builder cmdLineBuilder = BuildData.CommandLine.newBuilder();
for (UnparsedOptionValueDescription option :
event.getStartupOptions().asListOfUnparsedOptions()) {
cmdLineBuilder.addStartupOptions(getOption(option));
}
for (UnparsedOptionValueDescription option : event.getOptions().asListOfUnparsedOptions()) {
if (option.getName().equals("client_env")) {
String env[] = option.getUnparsedValue().split("=");
if (env.length == 1) {
builder.addClientEnv(
EnvironmentVar.newBuilder().setName(env[0]).setValue("true").build());
} else if (env.length == 2) {
builder.addClientEnv(
EnvironmentVar.newBuilder().setName(env[0]).setValue(env[1]).build());
}
} else {
cmdLineBuilder.addOptions(getOption(option));
}
}
for (String residue : event.getOptions().getResidue()) {
cmdLineBuilder.addResidue(residue);
}
builder.setCommandLine(cmdLineBuilder.build());
// This can be called before handleOptions, so the BuildData is stored until we know if it
// should be sent somewhere.
optionsBuildData = builder.build();
}
@Subscribe
public void commandStartEvent(CommandStartEvent event) {
BuildData.Builder builder = BuildData.newBuilder()
.setBuildId(event.getCommandId().toString())
.setCommandName(event.getCommandName())
.setWorkingDir(event.getWorkingDirectory().getPathString());
sender.send("start", builder.build());
}
@Subscribe
public void parsingComplete(TargetParsingCompleteEvent event) {
BuildData.Builder builder = BuildData.newBuilder();
for (Target target : event.getTargets()) {
builder.addTargetsBuilder()
.setLabel(target.getLabel().toString())
.setRuleKind(target.getTargetKind()).build();
}
sender.send("targets", builder.build());
}
@Subscribe
public void testFinished(TestResult result) {
BuildData.Builder builder = BuildData.newBuilder();
BuildData.Target.Builder targetBuilder = BuildData.Target.newBuilder();
targetBuilder.setLabel(result.getLabel());
TestData.Builder testDataBuilder = TestData.newBuilder();
testDataBuilder.setPassed(result.getData().getTestPassed());
if (!result.getData().getTestPassed()) {
testDataBuilder.setLog(getLog(result.getTestLogPath().toString()));
}
targetBuilder.setTestData(testDataBuilder);
builder.addTargets(targetBuilder);
sender.send("test", builder.build());
}
private Log getLog(String logPath) {
Log.Builder builder = Log.newBuilder().setPath(logPath);
File log = new File(logPath);
try {
long fileSize = Files.size(log.toPath());
if (fileSize > ONE_MB) {
fileSize = ONE_MB;
builder.setTruncated(true);
}
byte buffer[] = new byte[(int) fileSize];
try (FileInputStream in = new FileInputStream(log)) {
ByteStreams.readFully(in, buffer);
}
builder.setContents(ByteString.copyFrom(buffer));
} catch (IOException e) {
env
.getReporter()
.getOutErr()
.printOutLn("Error reading log file " + logPath + ": " + e.getMessage());
// TODO(kchodorow): add this info to the proto and send.
}
return builder.build();
}
@Override
public void blazeShutdown() {
executorService.shutdownNow();
}
private BuildData.CommandLine.Option getOption(UnparsedOptionValueDescription option) {
Option.Builder optionBuilder = Option.newBuilder();
optionBuilder.setName(option.getName());
if (option.getSource() != null) {
optionBuilder.setSource(option.getSource());
}
Object value = option.getUnparsedValue();
if (value != null) {
if (value instanceof Iterable<?>) {
for (Object v : ((Iterable<?>) value)) {
if (v != null) {
optionBuilder.addValue(v.toString());
}
}
} else {
optionBuilder.addValue(value.toString());
}
}
return optionBuilder.build();
}
private interface Sendable {
void send(final String suffix, final BuildData message);
}
private static class SenderException extends Exception {
SenderException(String message, Throwable ex) {
super(message, ex);
}
SenderException(String message) {
super(message);
}
Event toEvent() {
if (getCause() != null) {
return Event.error(getMessage() + ": " + getCause().getMessage());
} else {
return Event.error(getMessage());
}
}
}
private static class Sender implements Sendable {
private final URL url;
private final String buildId;
private final String secret;
private final Reporter reporter;
private final ExecutorService executorService;
public Sender(String url, String secret,
CommandEnvironment env, ExecutorService executorService) throws SenderException {
this.reporter = env.getReporter();
this.secret = readSecret(secret, reporter);
try {
this.url = new URL(url);
if (!this.secret.isEmpty()) {
if (!(this.url.getProtocol().equals("https") || this.url.getHost().equals("localhost")
|| this.url.getHost().matches("^127.0.0.[0-9]+$"))) {
reporter.handle(Event.warn("Using authentication over unsecure channel, "
+ "consider using HTTPS."));
}
}
} catch (MalformedURLException e) {
throw new SenderException("Invalid server url " + url, e);
}
this.buildId = env.getCommandId().toString();
this.executorService = executorService;
sendMessage("test", null); // test connecting to the server.
reporter.handle(Event.info("Results are being streamed to " + url + "/result/" + buildId));
}
private static String readSecret(String secretFile, Reporter reporter) throws SenderException {
if (secretFile.isEmpty()) {
return "";
}
Path secretPath = new File(secretFile).toPath();
if (!Files.isReadable(secretPath)) {
throw new SenderException("Secret file " + secretFile + " doesn't exists or is unreadable");
}
try {
if (Files.getPosixFilePermissions(secretPath).contains(PosixFilePermission.OTHERS_READ)
|| Files.getPosixFilePermissions(secretPath).contains(PosixFilePermission.GROUP_READ)) {
reporter.handle(Event.warn("Secret file " + secretFile + " is readable by non-owner. "
+ "It is recommended to set its permission to 0600 (read-write only by the owner)."));
}
return new String(Files.readAllBytes(secretPath), StandardCharsets.UTF_8).trim();
} catch (IOException e) {
throw new SenderException("Invalid secret file " + secretFile, e);
}
}
private void sendMessage(final String suffix, final HttpEntity message)
throws SenderException {
HttpParams httpParams = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(httpParams, 5000);
HttpConnectionParams.setSoTimeout(httpParams, 5000);
HttpClient httpClient = new DefaultHttpClient(httpParams);
HttpPost httppost = new HttpPost(url + "/" + suffix + "/" + buildId);
if (message != null) {
httppost.setHeader(HttpHeaders.CONTENT_TYPE, "application/x-protobuf");
httppost.setEntity(message);
}
if (!secret.isEmpty()) {
httppost.setHeader(DASH_SECRET_HEADER, secret);
}
StatusLine status;
try {
status = httpClient.execute(httppost).getStatusLine();
} catch (IOException e) {
throw new SenderException("Error sending results to " + url, e);
}
if (status.getStatusCode() == HttpStatus.SC_FORBIDDEN) {
throw new SenderException("Permission denied while sending results to " + url
+ ". Did you specified --dash_secret?");
}
}
@Override
public void send(final String suffix, final BuildData message) {
executorService.submit(new Runnable() {
@Override
public void run() {
try {
sendMessage(suffix, new ByteArrayEntity(message.toByteArray()));
} catch (SenderException ex) {
reporter.handle(ex.toEvent());
}
}
});
}
}
private static class NoOpSender implements Sendable {
public NoOpSender() {
}
@Override
public void send(String suffix, BuildData message) {
}
}
}