blob: 8b7065fdc654462dad5747c60eb7d397a3708b68 [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 org.apache.kudu.gradle;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.FileCollection;
import org.gradle.api.file.FileTree;
import org.gradle.api.internal.tasks.testing.TestClassProcessor;
import org.gradle.api.internal.tasks.testing.TestClassRunInfo;
import org.gradle.api.internal.tasks.testing.TestResultProcessor;
import org.gradle.api.internal.tasks.testing.detection.DefaultTestClassScanner;
import org.gradle.api.internal.tasks.testing.detection.TestFrameworkDetector;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
import org.gradle.api.tasks.testing.Test;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* This task is used in our top build.gradle file. It is called
* by dist_test.py to generate the needed .isolate and .gen.json
* files needed to run the distributed tests.
*/
public class DistTestTask extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(DistTestTask.class);
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.create();
String distTestBin = getProject().getRootDir() + "/../build-support/dist_test.py";
@OutputDirectory
File outputDir = new File(getProject().getBuildDir(), "dist-test");
private List<Test> testTasks = Lists.newArrayList();
private boolean collectTmpDir = false;
/**
* Called by build.gradle to add test tasks to be considered for dist-tests.
*/
public void addTestTask(Test t) {
testTasks.add(t);
}
@Option(option = "classes",
description = "Sets test class to be included, '*' is supported.")
public DistTestTask setClassPattern(List<String> classPattern) {
for (Test t : testTasks) {
// TODO: this requires a glob like **/*Foo* instead of just *Foo*
t.setIncludes(classPattern);
}
return this;
}
/**
* Not actually used, but gradle mandates that the @Input annotation be placed
* on a getter, and we need @Input so that the task is rerun if the value of
* the 'collect-tmpdir' option changes.
*/
@Input
public boolean getCollectTmpDir() {
return collectTmpDir;
}
@Option(option = "collect-tmpdir",
description = "Archives the test's temp directory as an artifact if the test fails.")
public DistTestTask setCollectTmpdir() {
collectTmpDir = true;
return this;
}
@InputFiles
public FileCollection getInputClasses() {
FileCollection fc = getProject().files(); // Create an empty FileCollection.
for (Test t : testTasks) {
fc = fc.plus(t.getCandidateClassFiles());
}
return fc;
}
@TaskAction
public void doStuff() throws IOException {
getProject().delete(outputDir);
getProject().mkdir(outputDir);
List<String> baseDeps = getBaseDeps();
for (Test t : testTasks) {
List<String> testClassNames = collectTestNames(t);
for (String c : testClassNames) {
File isolateFile = new File(outputDir, c + ".isolate");
File isolatedFile = new File(outputDir, c + ".isolated");
File genJsonFile = new File(outputDir, c + ".gen.json");
Files.asCharSink(isolateFile, UTF_8).write(genIsolate(outputDir.toPath(), t, c, baseDeps));
// Write the gen.json
GenJson gen = new GenJson();
gen.args = ImmutableList.of(
"-i", isolateFile.toString(),
"-s", isolatedFile.toString());
gen.dir = outputDir.toString();
gen.name = c;
Files.asCharSink(genJsonFile, UTF_8).write(GSON.toJson(gen));
}
}
}
/**
* Calls dist_test.py to get the c++ "base" dependencies so that we can
* include them in the .isolate files.
*
* Note: This currently fails OSX because dump_base_deps use ldd.
*/
private List<String> getBaseDeps() throws IOException {
Process proc = new ProcessBuilder(distTestBin,
"internal",
"dump_base_deps")
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start();
try (InputStream is = proc.getInputStream()) {
return new Gson().fromJson(new InputStreamReader(is, UTF_8),
new TypeToken<List<String>>(){}.getType());
}
}
/**
* @return all test result reporting environment variables and their values,
* in a format suitable for consumption by run_dist_test.py.
*/
private List<String> getTestResultReportingEnvironmentVariables() {
ImmutableList.Builder<String> args = new ImmutableList.Builder<>();
String enabled = System.getenv("KUDU_REPORT_TEST_RESULTS");
if (enabled != null && Integer.parseInt(enabled) > 0) {
for (String ev : ImmutableList.of("KUDU_REPORT_TEST_RESULTS",
"BUILD_CONFIG",
"BUILD_TAG",
"GIT_REVISION",
"TEST_RESULT_SERVER")) {
String evValue = System.getenv(ev);
if (evValue == null || evValue.isEmpty()) {
if (ev.equals("TEST_RESULT_SERVER")) {
// This one is optional.
continue;
}
throw new RuntimeException(
String.format("Required env variable %s is missing", ev));
}
args.add("-e");
args.add(String.format("%s=%s", ev, evValue));
}
}
return args.build();
}
private String genIsolate(Path isolateFileDir, Test test, String testClass,
List<String> baseDeps) throws IOException {
Path rootDir = test.getProject().getRootDir().toPath();
Path binDir = rootDir.resolve("../build/latest/bin").toRealPath();
Path buildSupportDir = rootDir.resolve("../build-support").toRealPath();
Path buildDir = rootDir.resolve("build");
File jarDir = buildDir.resolve("jars").toFile();
// Build classpath with relative paths.
List<String> classpath = Lists.newArrayList();
for (File f : test.getClasspath().getFiles()) {
File projectFile = f;
// This hack changes the path to dependent jars from the gradle cache
// in ~/.gradle/caches/... to a path to the jars copied under the project
// build directory. See the copyDistTestJars task in build.gradle to see
// the copy details.
if (projectFile.getAbsolutePath().contains(".gradle/caches/")) {
projectFile = new File(jarDir, projectFile.getName());
}
String s = isolateFileDir.relativize(projectFile.toPath().toAbsolutePath()).toString();
// Isolate requires that directories be listed with a trailing '/'.
if (projectFile.isDirectory()) {
s += "/";
}
// Gradle puts resources directories into the classpath even if they don't exist.
// isolate is unhappy with non-existent paths, though.
if (projectFile.exists()) {
classpath.add(s);
}
}
// Build up the actual Java command line to run the test.
ImmutableList.Builder<String> cmd = new ImmutableList.Builder<>();
cmd.add(isolateFileDir.relativize(buildSupportDir.resolve("run_dist_test.py")).toString());
if (collectTmpDir) {
cmd.add("--collect-tmpdir");
}
cmd.add("--test-language=java");
cmd.addAll(getTestResultReportingEnvironmentVariables());
cmd.add("--",
"-ea",
"-cp",
Joiner.on(":").join(classpath));
for (Map.Entry<String, Object> e : test.getSystemProperties().entrySet()) {
cmd.add("-D" + e.getKey() + "=" + e.getValue());
}
cmd.add("-DkuduBinDir=" + isolateFileDir.relativize(binDir),
"org.junit.runner.JUnitCore",
testClass);
// Output the actual JSON.
IsolateFileJson isolate = new IsolateFileJson();
isolate.variables.command = cmd.build();
isolate.variables.files.addAll(classpath);
for (String s : baseDeps) {
File f = new File(s);
String path = isolateFileDir.relativize(f.toPath().toAbsolutePath()).toString();
if (f.isDirectory()) {
path += "/";
}
isolate.variables.files.add(path);
}
String json = isolate.toJson();
// '.isolate' files are actually Python syntax, rather than true JSON.
// However, the two are close enough that just doing this replacement
// tends to work (we're assuming that no one has a quote character in a
// file path or system property.
return json.replace('"', '\'');
}
// This is internal API but required to get the filtered list of test classes and process them.
// See the gradle code here which was used for reference:
// https://github.com/gradle/gradle/blob/c2067eaa129af4c9c29ad08da39d1c853eec4c59/subprojects/testing-jvm/src/main/java/org/gradle/api/internal/tasks/testing/detection/DefaultTestExecuter.java#L104-L112
private List<String> collectTestNames(Test testTask) {
ClassNameCollectingProcessor processor = new ClassNameCollectingProcessor();
Runnable detector;
final FileTree testClassFiles = testTask.getCandidateClassFiles();
if (testTask.isScanForTestClasses()) {
TestFrameworkDetector testFrameworkDetector = testTask.getTestFramework().getDetector();
testFrameworkDetector.setTestClasses(testTask.getTestClassesDirs().getFiles());
testFrameworkDetector.setTestClasspath(testTask.getClasspath().getFiles());
detector = new DefaultTestClassScanner(testClassFiles, testFrameworkDetector, processor);
} else {
detector = new DefaultTestClassScanner(testClassFiles, null, processor);
}
detector.run();
LOGGER.debug("collected test class names: {}", processor.classNames);
return processor.classNames;
}
private static class ClassNameCollectingProcessor implements TestClassProcessor {
public List<String> classNames = new ArrayList<>();
@Override
public void startProcessing(TestResultProcessor testResultProcessor) {
// no-op
}
@Override
public void processTestClass(TestClassRunInfo testClassRunInfo) {
classNames.add(testClassRunInfo.getTestClassName());
}
@Override
public void stop() {
// no-op
}
@Override
public void stopNow() {
// no-op
}
}
/**
* Structured to generate Json that matches the expected .isolate format.
* See here for a description of the .isolate format:
* https://github.com/cloudera/dist_test/blob/master/grind/python/disttest/isolate.py
*/
private static class IsolateFileJson {
private static class Variables {
public List<String> files = new ArrayList<>();
public List<String> command;
}
Variables variables = new Variables();
public String toJson() {
return GSON.toJson(this);
}
}
/**
* Structured to generate Json that matches the expected .gen.json contents.
* See here for a description of the .gen.json contents:
* https://github.com/cloudera/dist_test/blob/master/grind/python/disttest/isolate.py
*/
private static class GenJson {
int version = 1;
String dir;
List<String> args;
String name;
}
}