diff --git a/gradle-plugins/README.rst b/gradle-plugins/README.rst new file mode 100644 index 0000000000..0ede7c216f --- /dev/null +++ b/gradle-plugins/README.rst @@ -0,0 +1,29 @@ +Gradle Plugins for Cordapps +=========================== + +The projects at this level of the project are gradle plugins for cordapps and are published to Maven Local with +the rest of the Corda libraries. + +.. note:: + + Some of the plugins here are duplicated with the ones in buildSrc. While the duplication is unwanted any + currently known solution (such as publishing from buildSrc or setting up a separate project/repo) would + introduce a two step build which is less convenient. + +Version number +-------------- + +To modify the version number edit constants.properties in root dir + +Installing +---------- + +If you need to bootstrap the corda repository you can install these plugins with + +.. code-block:: text + + cd publish-utils + ../../gradlew -u install + cd ../ + ../gradlew install + diff --git a/gradle-plugins/api-scanner/README.md b/gradle-plugins/api-scanner/README.md new file mode 100644 index 0000000000..a0256d480b --- /dev/null +++ b/gradle-plugins/api-scanner/README.md @@ -0,0 +1,79 @@ +# API Scanner + +Generates a text summary of Corda's public API that we can check for API-breaking changes. + +```bash +$ gradlew generateApi +``` + +See [here](../../docs/source/corda-api.rst) for Corda's public API strategy. We will need to +apply this plugin to other modules in future Corda releases as those modules' APIs stabilise. + +Basically, this plugin will document a module's `public` and `protected` classes/methods/fields, +excluding those from our `*.internal.*` packgages, any synthetic methods, bridge methods, or methods +identified as having Kotlin's `internal` scope. (Kotlin doesn't seem to have implemented `internal` +scope for classes or fields yet as these are currently `public` inside the `.class` file.) + +## Usage +Include this line in the `build.gradle` file of every Corda module that exports public API: + +```gradle +apply plugin: 'net.corda.plugins.api-scanner' +``` + +This will create a Gradle task called `scanApi` which will analyse that module's Jar artifacts. More precisely, +it will analyse all of the Jar artifacts that have not been assigned a Maven classifier, on the basis +that these should be the module's main artifacts. + +The `scanApi` task supports the following configuration options: +```gradle +scanApi { + // Make the classpath-scanning phase more verbose. + verbose = {true|false} + + // Enable / disable the task within this module. + enabled = {true|false} + + // Names of classes that should be excluded from the output. + excludeClasses = [ + ... + ] +} +``` + +All of the `ScanApi` tasks write their output files to their own `$buildDir/api` directory, where they +are collated into a single output file by the `GenerateApi` task. The `GenerateApi` task is declared +in the root project's `build.gradle` file: + +```gradle +task generateApi(type: net.corda.plugins.GenerateApi){ + baseName = "api-corda" +} +``` + +The final API file is written to `$buildDir/api/$baseName-$project.version.txt` + +### Sample Output +``` +public interface net.corda.core.contracts.Attachment extends net.corda.core.contracts.NamedByHash + public abstract void extractFile(String, java.io.OutputStream) + @org.jetbrains.annotations.NotNull public abstract List getSigners() + @org.jetbrains.annotations.NotNull public abstract java.io.InputStream open() + @org.jetbrains.annotations.NotNull public abstract jar.JarInputStream openAsJAR() +## +public interface net.corda.core.contracts.AttachmentConstraint + public abstract boolean isSatisfiedBy(net.corda.core.contracts.Attachment) +## +public final class net.corda.core.contracts.AttachmentResolutionException extends net.corda.core.flows.FlowException + public (net.corda.core.crypto.SecureHash) + @org.jetbrains.annotations.NotNull public final net.corda.core.crypto.SecureHash getHash() +## +``` + +#### Notes +The `GenerateApi` task will collate the output of every `ScanApi` task found either in the same project, +or in any of that project's subprojects. So it is _theoretically_ possible also to collate the API output +from subtrees of modules simply by defining a new `GenerateApi` task at the root of that subtree. + +## Plugin Installation +See [here](../README.rst) for full installation instructions. diff --git a/gradle-plugins/api-scanner/build.gradle b/gradle-plugins/api-scanner/build.gradle new file mode 100644 index 0000000000..c178472d58 --- /dev/null +++ b/gradle-plugins/api-scanner/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'java' +apply plugin: 'net.corda.plugins.publish-utils' +apply plugin: 'com.jfrog.artifactory' + +description "Generates a summary of the artifact's public API" + +repositories { + mavenCentral() +} + +dependencies { + compile gradleApi() + compile "io.github.lukehutch:fast-classpath-scanner:2.7.0" + testCompile "junit:junit:4.12" +} + +publish { + name project.name +} diff --git a/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ApiScanner.java b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ApiScanner.java new file mode 100644 index 0000000000..75f238891c --- /dev/null +++ b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ApiScanner.java @@ -0,0 +1,70 @@ +package net.corda.plugins; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.ConfigurationContainer; +import org.gradle.api.file.FileCollection; +import org.gradle.api.tasks.TaskCollection; +import org.gradle.jvm.tasks.Jar; + +public class ApiScanner implements Plugin { + + /** + * Identify the Gradle Jar tasks creating jars + * without Maven classifiers, and generate API + * documentation for them. + * @param p Current project. + */ + @Override + public void apply(Project p) { + p.getLogger().info("Applying API scanner to {}", p.getName()); + + ScannerExtension extension = p.getExtensions().create("scanApi", ScannerExtension.class); + + p.afterEvaluate(project -> { + TaskCollection jarTasks = project.getTasks() + .withType(Jar.class) + .matching(jarTask -> jarTask.getClassifier().isEmpty() && jarTask.isEnabled()); + if (jarTasks.isEmpty()) { + return; + } + + project.getLogger().info("Adding scanApi task to {}", project.getName()); + project.getTasks().create("scanApi", ScanApi.class, scanTask -> { + scanTask.setClasspath(compilationClasspath(project.getConfigurations())); + // Automatically creates a dependency on jar tasks. + scanTask.setSources(project.files(jarTasks)); + scanTask.setExcludeClasses(extension.getExcludeClasses()); + scanTask.setVerbose(extension.isVerbose()); + scanTask.setEnabled(extension.isEnabled()); + + // Declare this ScanApi task to be a dependency of any + // GenerateApi tasks belonging to any of our ancestors. + project.getRootProject().getTasks() + .withType(GenerateApi.class) + .matching(generateTask -> isAncestorOf(generateTask.getProject(), project)) + .forEach(generateTask -> generateTask.dependsOn(scanTask)); + }); + }); + } + + /* + * Recurse through a child project's parents until we reach the root, + * and return true iff we find our target project along the way. + */ + private static boolean isAncestorOf(Project target, Project child) { + Project p = child; + while (p != null) { + if (p == target) { + return true; + } + p = p.getParent(); + } + return false; + } + + private static FileCollection compilationClasspath(ConfigurationContainer configurations) { + return configurations.getByName("compile") + .plus(configurations.getByName("compileOnly")); + } +} diff --git a/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/GenerateApi.java b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/GenerateApi.java new file mode 100644 index 0000000000..9c87224075 --- /dev/null +++ b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/GenerateApi.java @@ -0,0 +1,61 @@ +package net.corda.plugins; + +import org.gradle.api.DefaultTask; +import org.gradle.api.file.FileCollection; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +import java.io.*; +import java.nio.file.Files; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; + +@SuppressWarnings("unused") +public class GenerateApi extends DefaultTask { + + private final File outputDir; + private String baseName; + + public GenerateApi() { + outputDir = new File(getProject().getBuildDir(), "api"); + baseName = "api-" + getProject().getName(); + } + + public void setBaseName(String baseName) { + this.baseName = baseName; + } + + @InputFiles + public FileCollection getSources() { + return getProject().files(getProject().getAllprojects().stream() + .flatMap(project -> project.getTasks() + .withType(ScanApi.class) + .matching(ScanApi::isEnabled) + .stream()) + .flatMap(scanTask -> scanTask.getTargets().getFiles().stream()) + .sorted(comparing(File::getName)) + .collect(toList()) + ); + } + + @OutputFile + public File getTarget() { + return new File(outputDir, String.format("%s-%s.txt", baseName, getProject().getVersion())); + } + + @TaskAction + public void generate() { + FileCollection apiFiles = getSources(); + if (!apiFiles.isEmpty()) { + try (OutputStream output = new BufferedOutputStream(new FileOutputStream(getTarget()))) { + for (File apiFile : apiFiles) { + Files.copy(apiFile.toPath(), output); + } + } catch (IOException e) { + getLogger().error("Failed to generate API file", e); + } + } + } +} diff --git a/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ScanApi.java b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ScanApi.java new file mode 100644 index 0000000000..eb2b7e5599 --- /dev/null +++ b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ScanApi.java @@ -0,0 +1,389 @@ +package net.corda.plugins; + +import io.github.lukehutch.fastclasspathscanner.FastClasspathScanner; +import io.github.lukehutch.fastclasspathscanner.scanner.ClassInfo; +import io.github.lukehutch.fastclasspathscanner.scanner.FieldInfo; +import io.github.lukehutch.fastclasspathscanner.scanner.MethodInfo; +import io.github.lukehutch.fastclasspathscanner.scanner.ScanResult; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; +import org.gradle.api.tasks.CompileClasspath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputFiles; +import org.gradle.api.tasks.TaskAction; + +import java.io.*; +import java.lang.annotation.Annotation; +import java.lang.annotation.Inherited; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; +import java.util.stream.StreamSupport; + +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; + +@SuppressWarnings("unused") +public class ScanApi extends DefaultTask { + private static final int CLASS_MASK = Modifier.classModifiers(); + private static final int INTERFACE_MASK = Modifier.interfaceModifiers() & ~Modifier.ABSTRACT; + private static final int METHOD_MASK = Modifier.methodModifiers(); + private static final int FIELD_MASK = Modifier.fieldModifiers(); + private static final int VISIBILITY_MASK = Modifier.PUBLIC | Modifier.PROTECTED; + + private static final Set ANNOTATION_BLACKLIST; + static { + Set blacklist = new LinkedHashSet<>(); + blacklist.add("kotlin.jvm.JvmOverloads"); + ANNOTATION_BLACKLIST = unmodifiableSet(blacklist); + } + + /** + * This information has been lifted from: + * @link Metadata.kt + */ + private static final String KOTLIN_METADATA = "kotlin.Metadata"; + private static final String KOTLIN_CLASSTYPE_METHOD = "k"; + private static final int KOTLIN_SYNTHETIC = 3; + + private final ConfigurableFileCollection sources; + private final ConfigurableFileCollection classpath; + private final Set excludeClasses; + private final File outputDir; + private boolean verbose; + + public ScanApi() { + sources = getProject().files(); + classpath = getProject().files(); + excludeClasses = new LinkedHashSet<>(); + outputDir = new File(getProject().getBuildDir(), "api"); + } + + @InputFiles + public FileCollection getSources() { + return sources; + } + + void setSources(FileCollection sources) { + this.sources.setFrom(sources); + } + + @CompileClasspath + @InputFiles + public FileCollection getClasspath() { + return classpath; + } + + void setClasspath(FileCollection classpath) { + this.classpath.setFrom(classpath); + } + + @Input + public Collection getExcludeClasses() { + return unmodifiableSet(excludeClasses); + } + + void setExcludeClasses(Collection excludeClasses) { + this.excludeClasses.clear(); + this.excludeClasses.addAll(excludeClasses); + } + + @OutputFiles + public FileCollection getTargets() { + return getProject().files( + StreamSupport.stream(sources.spliterator(), false) + .map(this::toTarget) + .collect(toList()) + ); + } + + public boolean isVerbose() { + return verbose; + } + + void setVerbose(boolean verbose) { + this.verbose = verbose; + } + + private File toTarget(File source) { + return new File(outputDir, source.getName().replaceAll(".jar$", ".txt")); + } + + @TaskAction + public void scan() { + try (Scanner scanner = new Scanner(classpath)) { + for (File source : sources) { + scanner.scan(source); + } + } catch (IOException e) { + getLogger().error("Failed to write API file", e); + } + } + + class Scanner implements Closeable { + private final URLClassLoader classpathLoader; + private final Class metadataClass; + private final Method classTypeMethod; + + @SuppressWarnings("unchecked") + Scanner(URLClassLoader classpathLoader) { + this.classpathLoader = classpathLoader; + + Class kClass; + Method kMethod; + try { + kClass = (Class) Class.forName(KOTLIN_METADATA, true, classpathLoader); + kMethod = kClass.getDeclaredMethod(KOTLIN_CLASSTYPE_METHOD); + } catch (ClassNotFoundException | NoSuchMethodException e) { + kClass = null; + kMethod = null; + } + + metadataClass = kClass; + classTypeMethod = kMethod; + } + + Scanner(FileCollection classpath) throws MalformedURLException { + this(new URLClassLoader(toURLs(classpath))); + } + + @Override + public void close() throws IOException { + classpathLoader.close(); + } + + void scan(File source) { + File target = toTarget(source); + try ( + URLClassLoader appLoader = new URLClassLoader(new URL[]{ toURL(source) }, classpathLoader); + PrintWriter writer = new PrintWriter(target, "UTF-8") + ) { + scan(writer, appLoader); + } catch (IOException e) { + getLogger().error("API scan has failed", e); + } + } + + void scan(PrintWriter writer, ClassLoader appLoader) { + ScanResult result = new FastClasspathScanner(getScanSpecification()) + .overrideClassLoaders(appLoader) + .ignoreParentClassLoaders() + .ignoreMethodVisibility() + .ignoreFieldVisibility() + .enableMethodInfo() + .enableFieldInfo() + .verbose(verbose) + .scan(); + writeApis(writer, result); + } + + private String[] getScanSpecification() { + String[] spec = new String[2 + excludeClasses.size()]; + spec[0] = "!"; // Don't blacklist system classes from the output. + spec[1] = "-dir:"; // Ignore classes on the filesystem. + + int i = 2; + for (String excludeClass : excludeClasses) { + spec[i++] = '-' + excludeClass; + } + return spec; + } + + private void writeApis(PrintWriter writer, ScanResult result) { + Map allInfo = result.getClassNameToClassInfo(); + result.getNamesOfAllClasses().forEach(className -> { + if (className.contains(".internal.")) { + // These classes belong to internal Corda packages. + return; + } + ClassInfo classInfo = allInfo.get(className); + if (classInfo.getClassLoaders() == null) { + // Ignore classes that belong to one of our target ClassLoader's parents. + return; + } + + Class javaClass = result.classNameToClassRef(className); + if (!isVisible(javaClass.getModifiers())) { + // Excludes private and package-protected classes + return; + } + + int kotlinClassType = getKotlinClassType(javaClass); + if (kotlinClassType == KOTLIN_SYNTHETIC) { + // Exclude classes synthesised by the Kotlin compiler. + return; + } + + writeClass(writer, classInfo, javaClass.getModifiers()); + writeMethods(writer, classInfo.getMethodAndConstructorInfo()); + writeFields(writer, classInfo.getFieldInfo()); + writer.println("##"); + }); + } + + private void writeClass(PrintWriter writer, ClassInfo classInfo, int modifiers) { + if (classInfo.isAnnotation()) { + /* + * Annotation declaration. + */ + writer.append(Modifier.toString(modifiers & INTERFACE_MASK)); + writer.append(" @interface ").print(classInfo); + } else if (classInfo.isStandardClass()) { + /* + * Class declaration. + */ + List annotationNames = toNames(readClassAnnotationsFor(classInfo)); + if (!annotationNames.isEmpty()) { + writer.append(asAnnotations(annotationNames)); + } + writer.append(Modifier.toString(modifiers & CLASS_MASK)); + writer.append(" class ").print(classInfo); + Set superclasses = classInfo.getDirectSuperclasses(); + if (!superclasses.isEmpty()) { + writer.append(" extends ").print(stringOf(superclasses)); + } + Set interfaces = classInfo.getDirectlyImplementedInterfaces(); + if (!interfaces.isEmpty()) { + writer.append(" implements ").print(stringOf(interfaces)); + } + } else { + /* + * Interface declaration. + */ + List annotationNames = toNames(readInterfaceAnnotationsFor(classInfo)); + if (!annotationNames.isEmpty()) { + writer.append(asAnnotations(annotationNames)); + } + writer.append(Modifier.toString(modifiers & INTERFACE_MASK)); + writer.append(" interface ").print(classInfo); + Set superinterfaces = classInfo.getDirectSuperinterfaces(); + if (!superinterfaces.isEmpty()) { + writer.append(" extends ").print(stringOf(superinterfaces)); + } + } + writer.println(); + } + + private void writeMethods(PrintWriter writer, List methods) { + sort(methods); + for (MethodInfo method : methods) { + if (isVisible(method.getAccessFlags()) // Only public and protected methods + && isValid(method.getAccessFlags(), METHOD_MASK) // Excludes bridge and synthetic methods + && !isKotlinInternalScope(method)) { + writer.append(" ").println(filterAnnotationsFor(method)); + } + } + } + + private void writeFields(PrintWriter output, List fields) { + sort(fields); + for (FieldInfo field : fields) { + if (isVisible(field.getAccessFlags()) && isValid(field.getAccessFlags(), FIELD_MASK)) { + output.append(" ").println(field); + } + } + } + + private int getKotlinClassType(Class javaClass) { + if (metadataClass != null) { + Annotation metadata = javaClass.getAnnotation(metadataClass); + if (metadata != null) { + try { + return (int) classTypeMethod.invoke(metadata); + } catch (IllegalAccessException | InvocationTargetException e) { + getLogger().error("Failed to read Kotlin annotation", e); + } + } + } + return 0; + } + + private List toNames(Collection classes) { + return classes.stream() + .map(ClassInfo::toString) + .filter(ScanApi::isApplicationClass) + .collect(toList()); + } + + private Set readClassAnnotationsFor(ClassInfo classInfo) { + Set annotations = new HashSet<>(classInfo.getAnnotations()); + annotations.addAll(selectInheritedAnnotations(classInfo.getSuperclasses())); + annotations.addAll(selectInheritedAnnotations(classInfo.getImplementedInterfaces())); + return annotations; + } + + private Set readInterfaceAnnotationsFor(ClassInfo classInfo) { + Set annotations = new HashSet<>(classInfo.getAnnotations()); + annotations.addAll(selectInheritedAnnotations(classInfo.getSuperinterfaces())); + return annotations; + } + + /** + * Returns those annotations which have themselves been annotated as "Inherited". + */ + private List selectInheritedAnnotations(Collection classes) { + return classes.stream() + .flatMap(cls -> cls.getAnnotations().stream()) + .filter(ann -> ann.hasMetaAnnotation(Inherited.class.getName())) + .collect(toList()); + } + + private MethodInfo filterAnnotationsFor(MethodInfo method) { + return new MethodInfo( + method.getClassName(), + method.getMethodName(), + method.getAccessFlags(), + method.getTypeDescriptor(), + method.getAnnotationNames().stream() + .filter(ScanApi::isVisibleAnnotation) + .collect(toList()) + ); + } + } + + private static boolean isVisibleAnnotation(String annotationName) { + return !ANNOTATION_BLACKLIST.contains(annotationName); + } + + private static boolean isKotlinInternalScope(MethodInfo method) { + return method.getMethodName().indexOf('$') >= 0; + } + + private static boolean isValid(int modifiers, int mask) { + return (modifiers & mask) == modifiers; + } + + private static boolean isVisible(int accessFlags) { + return (accessFlags & VISIBILITY_MASK) != 0; + } + + private static String stringOf(Collection items) { + return items.stream().map(ClassInfo::toString).collect(joining(", ")); + } + + private static String asAnnotations(Collection items) { + return items.stream().collect(joining(" @", "@", " ")); + } + + private static boolean isApplicationClass(String typeName) { + return !typeName.startsWith("java.") && !typeName.startsWith("kotlin."); + } + + private static URL toURL(File file) throws MalformedURLException { + return file.toURI().toURL(); + } + + private static URL[] toURLs(Iterable files) throws MalformedURLException { + List urls = new LinkedList<>(); + for (File file : files) { + urls.add(toURL(file)); + } + return urls.toArray(new URL[urls.size()]); + } +} diff --git a/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ScannerExtension.java b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ScannerExtension.java new file mode 100644 index 0000000000..4e77437cfc --- /dev/null +++ b/gradle-plugins/api-scanner/src/main/java/net/corda/plugins/ScannerExtension.java @@ -0,0 +1,37 @@ +package net.corda.plugins; + +import java.util.List; + +import static java.util.Collections.emptyList; + +@SuppressWarnings("unused") +public class ScannerExtension { + + private boolean verbose; + private boolean enabled = true; + private List excludeClasses = emptyList(); + + public boolean isVerbose() { + return verbose; + } + + public void setVerbose(boolean verbose) { + this.verbose = verbose; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getExcludeClasses() { + return excludeClasses; + } + + public void setExcludeClasses(List excludeClasses) { + this.excludeClasses = excludeClasses; + } +} diff --git a/gradle-plugins/api-scanner/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.api-scanner.properties b/gradle-plugins/api-scanner/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.api-scanner.properties new file mode 100644 index 0000000000..fc9e2277a5 --- /dev/null +++ b/gradle-plugins/api-scanner/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.api-scanner.properties @@ -0,0 +1 @@ +implementation-class=net.corda.plugins.ApiScanner diff --git a/gradle-plugins/build.gradle b/gradle-plugins/build.gradle new file mode 100644 index 0000000000..1e04813546 --- /dev/null +++ b/gradle-plugins/build.gradle @@ -0,0 +1,83 @@ +// This script exists just to allow bootstrapping the gradle plugins if maven central or jcenter are unavailable +// or if you are developing these plugins. See the readme for more information. + +buildscript { + // For sharing constants between builds + Properties constants = new Properties() + file("$projectDir/../constants.properties").withInputStream { constants.load(it) } + + // If you bump this version you must re-bootstrap the codebase. See the README for more information. + ext { + gradle_plugins_version = constants.getProperty("gradlePluginsVersion") + bouncycastle_version = constants.getProperty("bouncycastleVersion") + typesafe_config_version = constants.getProperty("typesafeConfigVersion") + jsr305_version = constants.getProperty("jsr305Version") + kotlin_version = constants.getProperty("kotlinVersion") + artifactory_plugin_version = constants.getProperty('artifactoryPluginVersion') + } + + repositories { + mavenLocal() + jcenter() + } + + dependencies { + classpath "net.corda.plugins:publish-utils:$gradle_plugins_version" + classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.7.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jfrog.buildinfo:build-info-extractor-gradle:$artifactory_plugin_version" + } +} + +apply plugin: 'net.corda.plugins.publish-utils' +apply plugin: 'com.jfrog.artifactory' + +allprojects { + version gradle_plugins_version + group 'net.corda.plugins' +} + +bintrayConfig { + user = System.getenv('CORDA_BINTRAY_USER') + key = System.getenv('CORDA_BINTRAY_KEY') + repo = 'corda' + org = 'r3' + licenses = ['Apache-2.0'] + vcsUrl = 'https://github.com/corda/corda' + projectUrl = 'https://github.com/corda/corda' + gpgSign = true + gpgPassphrase = System.getenv('CORDA_BINTRAY_GPG_PASSPHRASE') + publications = ['cordformation', 'quasar-utils', 'cordform-common', 'api-scanner', 'cordapp'] + license { + name = 'Apache-2.0' + url = 'https://www.apache.org/licenses/LICENSE-2.0' + distribution = 'repo' + } + developer { + id = 'R3' + name = 'R3' + email = 'dev@corda.net' + } +} + +artifactory { + publish { + contextUrl = 'https://ci-artifactory.corda.r3cev.com/artifactory' + repository { + repoKey = 'corda-dev' + username = 'teamcity' + password = System.getenv('CORDA_ARTIFACTORY_PASSWORD') + } + + defaults { + // Publish utils does not have a publish block because it would be circular for it to apply it's own + // extensions to itself + if(project.name == 'publish-utils') { + publications('publishUtils') + // Root project applies the plugin (for this block) but does not need to be published + } else if(project != rootProject) { + publications(project.extensions.publish.name()) + } + } + } +} \ No newline at end of file diff --git a/gradle-plugins/cordapp/README.md b/gradle-plugins/cordapp/README.md new file mode 100644 index 0000000000..6b0cebe690 --- /dev/null +++ b/gradle-plugins/cordapp/README.md @@ -0,0 +1,10 @@ +# Cordapp Gradle Plugin + +## Purpose + +To transform any project this plugin is applied to into a cordapp project that generates a cordapp JAR. + +## Effects + +Will modify the default JAR task to create a CorDapp format JAR instead [see here](https://docs.corda.net/cordapp-build-systems.html) +for more information. \ No newline at end of file diff --git a/gradle-plugins/cordapp/build.gradle b/gradle-plugins/cordapp/build.gradle new file mode 100644 index 0000000000..3d1ecb6b53 --- /dev/null +++ b/gradle-plugins/cordapp/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'kotlin' +apply plugin: 'net.corda.plugins.publish-utils' +apply plugin: 'com.jfrog.artifactory' + +description 'Turns a project into a cordapp project that produces cordapp fat JARs' + +repositories { + mavenCentral() + jcenter() +} + +dependencies { + compile gradleApi() + compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version" +} + +publish { + name project.name +} \ No newline at end of file diff --git a/gradle-plugins/cordapp/src/main/kotlin/net/corda/plugins/CordappPlugin.kt b/gradle-plugins/cordapp/src/main/kotlin/net/corda/plugins/CordappPlugin.kt new file mode 100644 index 0000000000..62119cf0f5 --- /dev/null +++ b/gradle-plugins/cordapp/src/main/kotlin/net/corda/plugins/CordappPlugin.kt @@ -0,0 +1,75 @@ +package net.corda.plugins + +import org.gradle.api.* +import org.gradle.api.artifacts.* +import org.gradle.jvm.tasks.Jar +import java.io.File + +/** + * The Cordapp plugin will turn a project into a cordapp project which builds cordapp JARs with the correct format + * and with the information needed to run on Corda. + */ +class CordappPlugin : Plugin { + override fun apply(project: Project) { + project.logger.info("Configuring ${project.name} as a cordapp") + + Utils.createCompileConfiguration("cordapp", project) + Utils.createCompileConfiguration("cordaCompile", project) + + val configuration: Configuration = project.configurations.create("cordaRuntime") + configuration.isTransitive = false + project.configurations.single { it.name == "runtime" }.extendsFrom(configuration) + + configureCordappJar(project) + } + + /** + * Configures this project's JAR as a Cordapp JAR + */ + private fun configureCordappJar(project: Project) { + // Note: project.afterEvaluate did not have full dependency resolution completed, hence a task is used instead + val task = project.task("configureCordappFatJar") + val jarTask = project.tasks.getByName("jar") as Jar + task.doLast { + jarTask.from(getDirectNonCordaDependencies(project).map { project.zipTree(it)}).apply { + exclude("META-INF/*.SF") + exclude("META-INF/*.DSA") + exclude("META-INF/*.RSA") + } + } + jarTask.dependsOn(task) + } + + private fun getDirectNonCordaDependencies(project: Project): Set { + project.logger.info("Finding direct non-corda dependencies for inclusion in CorDapp JAR") + val excludes = listOf( + mapOf("group" to "org.jetbrains.kotlin", "name" to "kotlin-stdlib"), + mapOf("group" to "org.jetbrains.kotlin", "name" to "kotlin-stdlib-jre8"), + mapOf("group" to "org.jetbrains.kotlin", "name" to "kotlin-reflect"), + mapOf("group" to "co.paralleluniverse", "name" to "quasar-core") + ) + + val runtimeConfiguration = project.configuration("runtime") + // The direct dependencies of this project + val excludeDeps = project.configuration("cordapp").allDependencies + + project.configuration("cordaCompile").allDependencies + + project.configuration("cordaRuntime").allDependencies + val directDeps = runtimeConfiguration.allDependencies - excludeDeps + // We want to filter out anything Corda related or provided by Corda, like kotlin-stdlib and quasar + val filteredDeps = directDeps.filter { dep -> + excludes.none { exclude -> (exclude["group"] == dep.group) && (exclude["name"] == dep.name) } + } + filteredDeps.forEach { + // net.corda or com.r3.corda may be a core dependency which shouldn't be included in this cordapp so give a warning + val group = it.group?.toString() ?: "" + if (group.startsWith("net.corda.") || group.startsWith("com.r3.corda.")) { + project.logger.warn("You appear to have included a Corda platform component ($it) using a 'compile' or 'runtime' dependency." + + "This can cause node stability problems. Please use 'corda' instead." + + "See http://docs.corda.net/cordapp-build-systems.html") + } else { + project.logger.info("Including dependency in CorDapp JAR: $it") + } + } + return filteredDeps.map { runtimeConfiguration.files(it) }.flatten().toSet() + } +} diff --git a/gradle-plugins/cordapp/src/main/kotlin/net/corda/plugins/Utils.kt b/gradle-plugins/cordapp/src/main/kotlin/net/corda/plugins/Utils.kt new file mode 100644 index 0000000000..7572cd9876 --- /dev/null +++ b/gradle-plugins/cordapp/src/main/kotlin/net/corda/plugins/Utils.kt @@ -0,0 +1,35 @@ +package net.corda.plugins + +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.artifacts.Configuration +import org.gradle.api.plugins.ExtraPropertiesExtension + +/** + * Mimics the "project.ext" functionality in groovy which provides a direct + * accessor to the "ext" extention (See: ExtraPropertiesExtension) + */ +@Suppress("UNCHECKED_CAST") +fun Project.ext(name: String): T = (extensions.findByName("ext") as ExtraPropertiesExtension).get(name) as T +fun Project.configuration(name: String): Configuration = configurations.single { it.name == name } + +class Utils { + companion object { + @JvmStatic + fun createCompileConfiguration(name: String, project: Project) { + if(!project.configurations.any { it.name == name }) { + val configuration = project.configurations.create(name) + configuration.isTransitive = false + project.configurations.single { it.name == "compile" }.extendsFrom(configuration) + } + } + fun createRuntimeConfiguration(name: String, project: Project) { + if(!project.configurations.any { it.name == name }) { + val configuration = project.configurations.create(name) + configuration.isTransitive = false + project.configurations.single { it.name == "runtime" }.extendsFrom(configuration) + } + } + } + +} \ No newline at end of file diff --git a/gradle-plugins/cordapp/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.cordapp.properties b/gradle-plugins/cordapp/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.cordapp.properties new file mode 100644 index 0000000000..90871e27c8 --- /dev/null +++ b/gradle-plugins/cordapp/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.cordapp.properties @@ -0,0 +1 @@ +implementation-class=net.corda.plugins.CordappPlugin diff --git a/gradle-plugins/cordform-common/README.md b/gradle-plugins/cordform-common/README.md new file mode 100644 index 0000000000..8b83c20e93 --- /dev/null +++ b/gradle-plugins/cordform-common/README.md @@ -0,0 +1,4 @@ +# Cordform Common + +This project contains common node types that both the Corda gradle plugin suite and Corda project +require in order to build Corda nodes. \ No newline at end of file diff --git a/gradle-plugins/cordform-common/build.gradle b/gradle-plugins/cordform-common/build.gradle new file mode 100644 index 0000000000..be2fa0cf16 --- /dev/null +++ b/gradle-plugins/cordform-common/build.gradle @@ -0,0 +1,24 @@ +apply plugin: 'java' +apply plugin: 'maven-publish' +apply plugin: 'net.corda.plugins.publish-utils' +apply plugin: 'com.jfrog.artifactory' + +repositories { + mavenCentral() +} + +// This tracks the gradle plugins version and not Corda +version gradle_plugins_version +group 'net.corda.plugins' + +dependencies { + // JSR 305: Nullability annotations + compile "com.google.code.findbugs:jsr305:$jsr305_version" + + // TypeSafe Config: for simple and human friendly config files. + compile "com.typesafe:config:$typesafe_config_version" +} + +publish { + name project.name +} \ No newline at end of file diff --git a/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformContext.java b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformContext.java new file mode 100644 index 0000000000..7687f68a11 --- /dev/null +++ b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformContext.java @@ -0,0 +1,7 @@ +package net.corda.cordform; + +import java.nio.file.Path; + +public interface CordformContext { + Path baseDirectory(String nodeName); +} diff --git a/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformDefinition.java b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformDefinition.java new file mode 100644 index 0000000000..fc62b1bbee --- /dev/null +++ b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformDefinition.java @@ -0,0 +1,40 @@ +package net.corda.cordform; + +import javax.annotation.Nonnull; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public abstract class CordformDefinition { + private Path nodesDirectory = Paths.get("build", "nodes"); + private final List> nodeConfigurers = new ArrayList<>(); + private final List cordappPackages = new ArrayList<>(); + + public Path getNodesDirectory() { + return nodesDirectory; + } + + public void setNodesDirectory(Path nodesDirectory) { + this.nodesDirectory = nodesDirectory; + } + + public List> getNodeConfigurers() { + return nodeConfigurers; + } + + public void addNode(Consumer configurer) { + nodeConfigurers.add(configurer); + } + + public List getCordappPackages() { + return cordappPackages; + } + + /** + * Make arbitrary changes to the node directories before they are started. + * @param context Lookup of node directory by node name. + */ + public abstract void setup(@Nonnull CordformContext context); +} diff --git a/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformNode.java b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformNode.java new file mode 100644 index 0000000000..52300f0f63 --- /dev/null +++ b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/CordformNode.java @@ -0,0 +1,173 @@ +package net.corda.cordform; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.emptyList; + +public class CordformNode implements NodeDefinition { + /** + * Path relative to the running node where the serialized NodeInfos are stored. + */ + public static final String NODE_INFO_DIRECTORY = "additional-node-infos"; + + protected static final String DEFAULT_HOST = "localhost"; + + /** + * Name of the node. + */ + private String name; + + public String getName() { + return name; + } + + /** + * Set the RPC users for this node. This configuration block allows arbitrary configuration. + * The recommended current structure is: + * [[['username': "username_here", 'password': "password_here", 'permissions': ["permissions_here"]]] + * The above is a list to a map of keys to values using Groovy map and list shorthands. + * + * Incorrect configurations will not cause a DSL error. + */ + public List> rpcUsers = emptyList(); + + /** + * Apply the notary configuration if this node is a notary. The map is the config structure of + * net.corda.node.services.config.NotaryConfig + */ + public Map notary = null; + + public Map extraConfig = null; + + protected Config config = ConfigFactory.empty(); + + public Config getConfig() { + return config; + } + + /** + * Set the name of the node. + * + * @param name The node name. + */ + public void name(String name) { + this.name = name; + setValue("myLegalName", name); + } + + /** + * Get the artemis address for this node. + * + * @return This node's P2P address. + */ + @Nonnull + public String getP2pAddress() { + return config.getString("p2pAddress"); + } + + /** + * Set the Artemis P2P port for this node on localhost. + * + * @param p2pPort The Artemis messaging queue port. + */ + public void p2pPort(int p2pPort) { + p2pAddress(DEFAULT_HOST + ':' + p2pPort); + } + + /** + * Set the Artemis P2P address for this node. + * + * @param p2pAddress The Artemis messaging queue host and port. + */ + public void p2pAddress(String p2pAddress) { + setValue("p2pAddress", p2pAddress); + } + + /** + * Returns the RPC address for this node, or null if one hasn't been specified. + */ + @Nullable + public String getRpcAddress() { + if (config.hasPath("rpcSettings.address")) { + return config.getConfig("rpcSettings").getString("address"); + } + return getOptionalString("rpcAddress"); + } + + /** + * Set the Artemis RPC port for this node on localhost. + * + * @param rpcPort The Artemis RPC queue port. + * @deprecated Use {@link CordformNode#rpcSettings(RpcSettings)} instead. + */ + @Deprecated + public void rpcPort(int rpcPort) { + rpcAddress(DEFAULT_HOST + ':' + rpcPort); + } + + /** + * Set the Artemis RPC address for this node. + * + * @param rpcAddress The Artemis RPC queue host and port. + * @deprecated Use {@link CordformNode#rpcSettings(RpcSettings)} instead. + */ + @Deprecated + public void rpcAddress(String rpcAddress) { + setValue("rpcAddress", rpcAddress); + } + + /** + * Returns the address of the web server that will connect to the node, or null if one hasn't been specified. + */ + @Nullable + public String getWebAddress() { + return getOptionalString("webAddress"); + } + + /** + * Configure a webserver to connect to the node via RPC. This port will specify the port it will listen on. The node + * must have an RPC address configured. + */ + public void webPort(int webPort) { + webAddress(DEFAULT_HOST + ':' + webPort); + } + + /** + * Configure a webserver to connect to the node via RPC. This address will specify the port it will listen on. The node + * must have an RPC address configured. + */ + public void webAddress(String webAddress) { + setValue("webAddress", webAddress); + } + + /** + * Specifies RPC settings for the node. + */ + public void rpcSettings(RpcSettings settings) { + config = settings.addTo("rpcSettings", config); + } + + /** + * Set the path to a file with optional properties, which are appended to the generated node.conf file. + * + * @param configFile The file path. + */ + public void configFile(String configFile) { + setValue("configFile", configFile); + } + + private String getOptionalString(String path) { + return config.hasPath(path) ? config.getString(path) : null; + } + + private void setValue(String path, Object value) { + config = config.withValue(path, ConfigValueFactory.fromAnyRef(value)); + } +} diff --git a/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/NodeDefinition.java b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/NodeDefinition.java new file mode 100644 index 0000000000..0b86b98627 --- /dev/null +++ b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/NodeDefinition.java @@ -0,0 +1,9 @@ +package net.corda.cordform; + +import com.typesafe.config.Config; + +public interface NodeDefinition { + String getName(); + + Config getConfig(); +} diff --git a/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/RpcSettings.java b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/RpcSettings.java new file mode 100644 index 0000000000..e429bb0ca6 --- /dev/null +++ b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/RpcSettings.java @@ -0,0 +1,56 @@ +package net.corda.cordform; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +public final class RpcSettings { + + private Config config = ConfigFactory.empty(); + + /** + * RPC address for the node. + */ + public final void address(final String value) { + setValue("address", value); + } + + /** + * RPC admin address for the node (necessary if [useSsl] is false or unset). + */ + public final void adminAddress(final String value) { + setValue("adminAddress", value); + } + + /** + * Specifies whether the node RPC layer will require SSL from clients. + */ + public final void useSsl(final Boolean value) { + setValue("useSsl", value); + } + + /** + * Specifies whether the RPC broker is separate from the node. + */ + public final void standAloneBroker(final Boolean value) { + setValue("standAloneBroker", value); + } + + /** + * Specifies SSL certificates options for the RPC layer. + */ + public final void ssl(final SslOptions options) { + config = options.addTo("ssl", config); + } + + final Config addTo(final String key, final Config config) { + if (this.config.isEmpty()) { + return config; + } + return config.withValue(key, this.config.root()); + } + + private void setValue(String path, Object value) { + config = config.withValue(path, ConfigValueFactory.fromAnyRef(value)); + } +} diff --git a/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/SslOptions.java b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/SslOptions.java new file mode 100644 index 0000000000..da3cc22288 --- /dev/null +++ b/gradle-plugins/cordform-common/src/main/java/net/corda/cordform/SslOptions.java @@ -0,0 +1,56 @@ +package net.corda.cordform; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +public final class SslOptions { + + private Config config = ConfigFactory.empty(); + + /** + * Password for the keystore. + */ + public final void keyStorePassword(final String value) { + setValue("keyStorePassword", value); + } + + /** + * Password for the truststore. + */ + public final void trustStorePassword(final String value) { + setValue("trustStorePassword", value); + } + + /** + * Directory under which key stores are to be placed. + */ + public final void certificatesDirectory(final String value) { + setValue("certificatesDirectory", value); + } + + /** + * Absolute path to SSL keystore. Default: "[certificatesDirectory]/sslkeystore.jks" + */ + public final void sslKeystore(final String value) { + setValue("sslKeystore", value); + } + + /** + * Absolute path to SSL truststore. Default: "[certificatesDirectory]/truststore.jks" + */ + public final void trustStoreFile(final String value) { + setValue("trustStoreFile", value); + } + + final Config addTo(final String key, final Config config) { + if (this.config.isEmpty()) { + return config; + } + return config.withValue(key, this.config.root()); + } + + private void setValue(String path, Object value) { + config = config.withValue(path, ConfigValueFactory.fromAnyRef(value)); + } +} diff --git a/gradle-plugins/cordformation/README.rst b/gradle-plugins/cordformation/README.rst new file mode 100644 index 0000000000..f619737a91 --- /dev/null +++ b/gradle-plugins/cordformation/README.rst @@ -0,0 +1 @@ +Please refer to the documentation in /doc/build/html/running-a-node.html#cordformation. \ No newline at end of file diff --git a/gradle-plugins/cordformation/build.gradle b/gradle-plugins/cordformation/build.gradle new file mode 100644 index 0000000000..6c22f78a9e --- /dev/null +++ b/gradle-plugins/cordformation/build.gradle @@ -0,0 +1,62 @@ +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'kotlin' +apply plugin: 'net.corda.plugins.publish-utils' +apply plugin: 'com.jfrog.artifactory' + +description 'A small gradle plugin for adding some basic Quasar tasks and configurations to reduce build.gradle bloat.' + +repositories { + mavenCentral() +} + +configurations { + noderunner + compile.extendsFrom noderunner +} + +sourceSets { + runnodes { + kotlin { + srcDir file('src/noderunner/kotlin') + compileClasspath += configurations.noderunner + } + } +} + +dependencies { + compile gradleApi() + compile project(":cordapp") + compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version" + + noderunner "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version" + + compile project(':cordform-common') +} + +task createNodeRunner(type: Jar, dependsOn: [classes]) { + manifest { + attributes('Main-Class': 'net.corda.plugins.NodeRunnerKt') + } + classifier = 'fatjar' + from { configurations.noderunner.collect { it.isDirectory() ? it : zipTree(it) } } + from sourceSets.runnodes.output +} + +jar { + from(createNodeRunner) { + rename { 'net/corda/plugins/runnodes.jar' } + } +} + +publish { + name project.name +} diff --git a/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Cordform.kt b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Cordform.kt new file mode 100644 index 0000000000..1d44484eb2 --- /dev/null +++ b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Cordform.kt @@ -0,0 +1,218 @@ +package net.corda.plugins + +import groovy.lang.Closure +import net.corda.cordform.CordformDefinition +import org.apache.tools.ant.filters.FixCrLfFilter +import org.gradle.api.DefaultTask +import org.gradle.api.plugins.JavaPluginConvention +import org.gradle.api.tasks.SourceSet.MAIN_SOURCE_SET_NAME +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.lang.reflect.InvocationTargetException +import java.net.URLClassLoader +import java.nio.file.Path +import java.nio.file.Paths +import java.util.jar.JarInputStream + +/** + * Creates nodes based on the configuration of this task in the gradle configuration DSL. + * + * See documentation for examples. + */ +@Suppress("unused") +open class Cordform : DefaultTask() { + private companion object { + val nodeJarName = "corda.jar" + private val defaultDirectory: Path = Paths.get("build", "nodes") + } + + /** + * Optionally the name of a CordformDefinition subclass to which all configuration will be delegated. + */ + @Suppress("MemberVisibilityCanPrivate") + var definitionClass: String? = null + private var directory = defaultDirectory + private val nodes = mutableListOf() + + /** + * Set the directory to install nodes into. + * + * @param directory The directory the nodes will be installed into. + */ + fun directory(directory: String) { + this.directory = Paths.get(directory) + } + + /** + * Add a node configuration. + * + * @param configureClosure A node configuration that will be deployed. + */ + @Suppress("MemberVisibilityCanPrivate") + fun node(configureClosure: Closure) { + nodes += project.configure(Node(project), configureClosure) as Node + } + + /** + * Add a node configuration + * + * @param configureFunc A node configuration that will be deployed + */ + @Suppress("MemberVisibilityCanPrivate") + fun node(configureFunc: Node.() -> Any?): Node { + val node = Node(project).apply { configureFunc() } + nodes += node + return node + } + + /** + * Returns a node by name. + * + * @param name The name of the node as specified in the node configuration DSL. + * @return A node instance. + */ + private fun getNodeByName(name: String): Node? = nodes.firstOrNull { it.name == name } + + /** + * Installs the run script into the nodes directory. + */ + private fun installRunScript() { + project.copy { + it.apply { + from(Cordformation.getPluginFile(project, "net/corda/plugins/runnodes.jar")) + fileMode = Cordformation.executableFileMode + into("$directory/") + } + } + + project.copy { + it.apply { + from(Cordformation.getPluginFile(project, "net/corda/plugins/runnodes")) + // Replaces end of line with lf to avoid issues with the bash interpreter and Windows style line endings. + filter(mapOf("eol" to FixCrLfFilter.CrLf.newInstance("lf")), FixCrLfFilter::class.java) + fileMode = Cordformation.executableFileMode + into("$directory/") + } + } + + project.copy { + it.apply { + from(Cordformation.getPluginFile(project, "net/corda/plugins/runnodes.bat")) + into("$directory/") + } + } + } + + /** + * The definitionClass needn't be compiled until just before our build method, so we load it manually via sourceSets.main.runtimeClasspath. + */ + private fun loadCordformDefinition(): CordformDefinition { + val plugin = project.convention.getPlugin(JavaPluginConvention::class.java) + val classpath = plugin.sourceSets.getByName(MAIN_SOURCE_SET_NAME).runtimeClasspath + val urls = classpath.files.map { it.toURI().toURL() }.toTypedArray() + return URLClassLoader(urls, CordformDefinition::class.java.classLoader) + .loadClass(definitionClass) + .asSubclass(CordformDefinition::class.java) + .newInstance() + } + + /** + * The NetworkBootstrapper needn't be compiled until just before our build method, so we load it manually via sourceSets.main.runtimeClasspath. + */ + private fun loadNetworkBootstrapperClass(): Class<*> { + val plugin = project.convention.getPlugin(JavaPluginConvention::class.java) + val classpath = plugin.sourceSets.getByName(MAIN_SOURCE_SET_NAME).runtimeClasspath + val urls = classpath.files.map { it.toURI().toURL() }.toTypedArray() + return URLClassLoader(urls, javaClass.classLoader).loadClass("net.corda.nodeapi.internal.network.NetworkBootstrapper") + } + + /** + * This task action will create and install the nodes based on the node configurations added. + */ + @TaskAction + fun build() { + project.logger.info("Running Cordform task") + initializeConfiguration() + nodes.forEach(Node::installConfig) + installCordaJar() + installRunScript() + bootstrapNetwork() + nodes.forEach(Node::build) + } + + /** + * Installs the corda fat JAR to the root directory, for the network bootstrapper to use. + */ + private fun installCordaJar() { + val cordaJar = Cordformation.verifyAndGetRuntimeJar(project, "corda") + project.copy { + it.apply { + from(cordaJar) + into(directory) + rename(cordaJar.name, nodeJarName) + fileMode = Cordformation.executableFileMode + } + } + } + + private fun initializeConfiguration() { + if (definitionClass != null) { + val cd = loadCordformDefinition() + // If the user has specified their own directory (even if it's the same default path) then let them know + // it's not used and should just rely on the one in CordformDefinition + require(directory === defaultDirectory) { + "'directory' cannot be used when 'definitionClass' is specified. Use CordformDefinition.nodesDirectory instead." + } + directory = cd.nodesDirectory + val cordapps = cd.getMatchingCordapps() + cd.nodeConfigurers.forEach { + val node = node { } + it.accept(node) + node.additionalCordapps.addAll(cordapps) + node.rootDir(directory) + } + cd.setup { nodeName -> project.projectDir.toPath().resolve(getNodeByName(nodeName)!!.nodeDir.toPath()) } + } else { + nodes.forEach { + it.rootDir(directory) + } + } + } + + private fun bootstrapNetwork() { + val networkBootstrapperClass = loadNetworkBootstrapperClass() + val networkBootstrapper = networkBootstrapperClass.newInstance() + val bootstrapMethod = networkBootstrapperClass.getMethod("bootstrap", Path::class.java).apply { isAccessible = true } + // Call NetworkBootstrapper.bootstrap + try { + val rootDir = project.projectDir.toPath().resolve(directory).toAbsolutePath().normalize() + bootstrapMethod.invoke(networkBootstrapper, rootDir) + } catch (e: InvocationTargetException) { + throw e.cause!! + } + } + + private fun CordformDefinition.getMatchingCordapps(): List { + val cordappJars = project.configuration("cordapp").files + return cordappPackages.map { `package` -> + val cordappsWithPackage = cordappJars.filter { it.containsPackage(`package`) } + when (cordappsWithPackage.size) { + 0 -> throw IllegalArgumentException("There are no cordapp dependencies containing the package $`package`") + 1 -> cordappsWithPackage[0] + else -> throw IllegalArgumentException("More than one cordapp dependency contains the package $`package`: $cordappsWithPackage") + } + } + } + + private fun File.containsPackage(`package`: String): Boolean { + JarInputStream(inputStream()).use { + while (true) { + val name = it.nextJarEntry?.name ?: break + if (name.endsWith(".class") && name.replace('/', '.').startsWith(`package`)) { + return true + } + } + return false + } + } +} diff --git a/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Cordformation.kt b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Cordformation.kt new file mode 100644 index 0000000000..82a818992d --- /dev/null +++ b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Cordformation.kt @@ -0,0 +1,62 @@ +package net.corda.plugins + +import org.gradle.api.Plugin +import org.gradle.api.Project +import java.io.File + +/** + * The Cordformation plugin deploys nodes to a directory in a state ready to be used by a developer for experimentation, + * testing, and debugging. It will prepopulate several fields in the configuration and create a simple node runner. + */ +class Cordformation : Plugin { + internal companion object { + const val CORDFORMATION_TYPE = "cordformationInternal" + + /** + * Gets a resource file from this plugin's JAR file. + * + * @param project The project environment this plugin executes in. + * @param filePathInJar The file in the JAR, relative to root, you wish to access. + * @return A file handle to the file in the JAR. + */ + fun getPluginFile(project: Project, filePathInJar: String): File { + val archive = project.rootProject.buildscript.configurations + .single { it.name == "classpath" } + .first { it.name.contains("cordformation") } + return project.rootProject.resources.text + .fromArchiveEntry(archive, filePathInJar) + .asFile() + } + + /** + * Gets a current built corda jar file + * + * @param project The project environment this plugin executes in. + * @param jarName The name of the JAR you wish to access. + * @return A file handle to the file in the JAR. + */ + fun verifyAndGetRuntimeJar(project: Project, jarName: String): File { + val releaseVersion = project.rootProject.ext("corda_release_version") + val maybeJar = project.configuration("runtime").filter { + "$jarName-$releaseVersion.jar" in it.toString() || "$jarName-r3-$releaseVersion.jar" in it.toString() + } + if (maybeJar.isEmpty) { + throw IllegalStateException("No $jarName JAR found. Have you deployed the Corda project to Maven? Looked for \"$jarName-$releaseVersion.jar\"") + } else { + val jar = maybeJar.singleFile + require(jar.isFile) + return jar + } + } + + val executableFileMode = "0755".toInt(8) + } + + override fun apply(project: Project) { + Utils.createCompileConfiguration("cordapp", project) + Utils.createRuntimeConfiguration(CORDFORMATION_TYPE, project) + // TODO: improve how we re-use existing declared external variables from root gradle.build + val jolokiaVersion = try { project.rootProject.ext("jolokia_version") } catch (e: Exception) { "1.3.7" } + project.dependencies.add(CORDFORMATION_TYPE, "org.jolokia:jolokia-jvm:$jolokiaVersion:agent") + } +} diff --git a/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Node.kt b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Node.kt new file mode 100644 index 0000000000..2f8cb543d0 --- /dev/null +++ b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/Node.kt @@ -0,0 +1,239 @@ +package net.corda.plugins + +import com.typesafe.config.ConfigFactory +import com.typesafe.config.ConfigRenderOptions +import com.typesafe.config.ConfigValueFactory +import groovy.lang.Closure +import net.corda.cordform.CordformNode +import org.gradle.api.Project +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path + +/** + * Represents a node that will be installed. + */ +class Node(private val project: Project) : CordformNode() { + companion object { + @JvmStatic + val webJarName = "corda-webserver.jar" + private val configFileProperty = "configFile" + } + + /** + * Set the list of CorDapps to install to the plugins directory. Each cordapp is a fully qualified Maven + * dependency name, eg: com.example:product-name:0.1 + * + * @note Your app will be installed by default and does not need to be included here. + * @note Type is any due to gradle's use of "GStrings" - each value will have "toString" called on it + */ + var cordapps = mutableListOf() + internal var additionalCordapps = mutableListOf() + internal lateinit var nodeDir: File + private set + internal lateinit var rootDir: File + private set + + /** + * Sets whether this node will use HTTPS communication. + * + * @param isHttps True if this node uses HTTPS communication. + */ + fun https(isHttps: Boolean) { + config = config.withValue("useHTTPS", ConfigValueFactory.fromAnyRef(isHttps)) + } + + /** + * Sets the H2 port for this node + */ + fun h2Port(h2Port: Int) { + config = config.withValue("h2port", ConfigValueFactory.fromAnyRef(h2Port)) + } + + fun useTestClock(useTestClock: Boolean) { + config = config.withValue("useTestClock", ConfigValueFactory.fromAnyRef(useTestClock)) + } + + /** + * Specifies RPC settings for the node. + */ + fun rpcSettings(configureClosure: Closure) { + val rpcSettings = project.configure(RpcSettings(project), configureClosure) as RpcSettings + config = rpcSettings.addTo("rpcSettings", config) + } + + /** + * Enables SSH access on given port + * + * @param sshdPort The port for SSH server to listen on + */ + fun sshdPort(sshdPort: Int?) { + config = config.withValue("sshd.port", ConfigValueFactory.fromAnyRef(sshdPort)) + } + + internal fun build() { + if (config.hasPath("webAddress")) { + installWebserverJar() + } + installAgentJar() + installBuiltCordapp() + installCordapps() + } + + internal fun rootDir(rootDir: Path) { + if (name == null) { + project.logger.error("Node has a null name - cannot create node") + throw IllegalStateException("Node has a null name - cannot create node") + } + // Parsing O= part directly because importing BouncyCastle provider in Cordformation causes problems + // with loading our custom X509EdDSAEngine. + val organizationName = name.trim().split(",").firstOrNull { it.startsWith("O=") }?.substringAfter("=") + val dirName = organizationName ?: name + this.rootDir = rootDir.toFile() + nodeDir = File(this.rootDir, dirName.replace("\\s", "")) + Files.createDirectories(nodeDir.toPath()) + } + + private fun configureProperties() { + config = config.withValue("database.runMigration", ConfigValueFactory.fromAnyRef(true)) + config = config.withValue("rpcUsers", ConfigValueFactory.fromIterable(rpcUsers)) + if (notary != null) { + config = config.withValue("notary", ConfigValueFactory.fromMap(notary)) + } + if (extraConfig != null) { + config = config.withFallback(ConfigFactory.parseMap(extraConfig)) + } + } + + /** + * Installs the corda webserver JAR to the node directory + */ + private fun installWebserverJar() { + val webJar = Cordformation.verifyAndGetRuntimeJar(project, "corda-webserver") + project.copy { + it.apply { + from(webJar) + into(nodeDir) + rename(webJar.name, webJarName) + } + } + } + + /** + * Installs this project's cordapp to this directory. + */ + private fun installBuiltCordapp() { + val cordappsDir = File(nodeDir, "cordapps") + project.copy { + it.apply { + from(project.tasks.getByName("jar")) + into(cordappsDir) + } + } + } + + /** + * Installs the jolokia monitoring agent JAR to the node/drivers directory + */ + private fun installAgentJar() { + // TODO: improve how we re-use existing declared external variables from root gradle.build + val jolokiaVersion = try { project.rootProject.ext("jolokia_version") } catch (e: Exception) { "1.3.7" } + val agentJar = project.configuration("runtime").files { + (it.group == "org.jolokia") && + (it.name == "jolokia-jvm") && + (it.version == jolokiaVersion) + // TODO: revisit when classifier attribute is added. eg && (it.classifier = "agent") + }.first() // should always be the jolokia agent fat jar: eg. jolokia-jvm-1.3.7-agent.jar + project.logger.info("Jolokia agent jar: $agentJar") + if (agentJar.isFile) { + val driversDir = File(nodeDir, "drivers") + project.copy { + it.apply { + from(agentJar) + into(driversDir) + } + } + } + } + + private fun createTempConfigFile(): File { + val options = ConfigRenderOptions + .defaults() + .setOriginComments(false) + .setComments(false) + .setFormatted(true) + .setJson(false) + val configFileText = config.root().render(options).split("\n").toList() + // Need to write a temporary file first to use the project.copy, which resolves directories correctly. + val tmpDir = File(project.buildDir, "tmp") + Files.createDirectories(tmpDir.toPath()) + var fileName = "${nodeDir.name}.conf" + val tmpConfFile = File(tmpDir, fileName) + Files.write(tmpConfFile.toPath(), configFileText, StandardCharsets.UTF_8) + return tmpConfFile + } + + /** + * Installs the configuration file to the root directory and detokenises it. + */ + internal fun installConfig() { + configureProperties() + val tmpConfFile = createTempConfigFile() + appendOptionalConfig(tmpConfFile) + project.copy { + it.apply { + from(tmpConfFile) + into(rootDir) + } + } + } + + /** + * Appends installed config file with properties from an optional file. + */ + private fun appendOptionalConfig(confFile: File) { + val optionalConfig: File? = when { + project.findProperty(configFileProperty) != null -> //provided by -PconfigFile command line property when running Gradle task + File(project.findProperty(configFileProperty) as String) + config.hasPath(configFileProperty) -> File(config.getString(configFileProperty)) + else -> null + } + + if (optionalConfig != null) { + if (!optionalConfig.exists()) { + project.logger.error("$configFileProperty '$optionalConfig' not found") + } else { + confFile.appendBytes(optionalConfig.readBytes()) + } + } + } + + /** + * Installs other cordapps to this node's cordapps directory. + */ + internal fun installCordapps() { + additionalCordapps.addAll(getCordappList()) + val cordappsDir = File(nodeDir, "cordapps") + project.copy { + it.apply { + from(additionalCordapps) + into(cordappsDir) + } + } + } + + /** + * Gets a list of cordapps based on what dependent cordapps were specified. + * + * @return List of this node's cordapps. + */ + private fun getCordappList(): Collection { + // Cordapps can sometimes contain a GString instance which fails the equality test with the Java string + @Suppress("RemoveRedundantCallsOfConversionMethods") + val cordapps: List = cordapps.map { it.toString() } + return project.configuration("cordapp").files { + cordapps.contains(it.group + ":" + it.name + ":" + it.version) + } + } +} diff --git a/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/RpcSettings.kt b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/RpcSettings.kt new file mode 100644 index 0000000000..2f46f0b2c5 --- /dev/null +++ b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/RpcSettings.kt @@ -0,0 +1,59 @@ +package net.corda.plugins + +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import com.typesafe.config.ConfigValueFactory +import groovy.lang.Closure +import org.gradle.api.Project + +class RpcSettings(private val project: Project) { + private var config: Config = ConfigFactory.empty() + + /** + * RPC address for the node. + */ + fun address(value: String) { + config += "address" to value + } + + /** + * RPC admin address for the node (necessary if [useSsl] is false or unset). + */ + fun adminAddress(value: String) { + config += "adminAddress" to value + } + + /** + * Specifies whether the node RPC layer will require SSL from clients. + */ + fun useSsl(value: Boolean) { + config += "useSsl" to value + } + + /** + * Specifies whether the RPC broker is separate from the node. + */ + fun standAloneBroker(value: Boolean) { + config += "standAloneBroker" to value + } + + /** + * Specifies SSL certificates options for the RPC layer. + */ + fun ssl(configureClosure: Closure) { + val sslOptions = project.configure(SslOptions(), configureClosure) as SslOptions + config = sslOptions.addTo("ssl", config) + } + + internal fun addTo(key: String, config: Config): Config { + if (this.config.isEmpty) { + return config + } + return config + (key to this.config.root()) + } +} + +internal operator fun Config.plus(entry: Pair): Config { + + return withValue(entry.first, ConfigValueFactory.fromAnyRef(entry.second)) +} \ No newline at end of file diff --git a/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/SslOptions.kt b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/SslOptions.kt new file mode 100644 index 0000000000..bb8fed288e --- /dev/null +++ b/gradle-plugins/cordformation/src/main/kotlin/net/corda/plugins/SslOptions.kt @@ -0,0 +1,50 @@ +package net.corda.plugins + +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory + +class SslOptions { + private var config: Config = ConfigFactory.empty() + + /** + * Password for the keystore. + */ + fun keyStorePassword(value: String) { + config += "keyStorePassword" to value + } + + /** + * Password for the truststore. + */ + fun trustStorePassword(value: String) { + config += "trustStorePassword" to value + } + + /** + * Directory under which key stores are to be placed. + */ + fun certificatesDirectory(value: String) { + config += "certificatesDirectory" to value + } + + /** + * Absolute path to SSL keystore. Default: "[certificatesDirectory]/sslkeystore.jks" + */ + fun sslKeystore(value: String) { + config += "sslKeystore" to value + } + + /** + * Absolute path to SSL truststore. Default: "[certificatesDirectory]/truststore.jks" + */ + fun trustStoreFile(value: String) { + config += "trustStoreFile" to value + } + + internal fun addTo(key: String, config: Config): Config { + if (this.config.isEmpty) { + return config + } + return config + (key to this.config.root()) + } +} \ No newline at end of file diff --git a/gradle-plugins/cordformation/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.cordformation.properties b/gradle-plugins/cordformation/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.cordformation.properties new file mode 100644 index 0000000000..4475d5c692 --- /dev/null +++ b/gradle-plugins/cordformation/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.cordformation.properties @@ -0,0 +1 @@ +implementation-class=net.corda.plugins.Cordformation diff --git a/gradle-plugins/cordformation/src/main/resources/net/corda/plugins/runnodes b/gradle-plugins/cordformation/src/main/resources/net/corda/plugins/runnodes new file mode 100644 index 0000000000..9e3ba4c5be --- /dev/null +++ b/gradle-plugins/cordformation/src/main/resources/net/corda/plugins/runnodes @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Allow the script to be run from outside the nodes directory. +basedir=$( dirname "$0" ) +cd "$basedir" + +if which osascript >/dev/null; then + /usr/libexec/java_home -v 1.8 --exec java -jar runnodes.jar "$@" +else + "${JAVA_HOME:+$JAVA_HOME/bin/}java" -jar runnodes.jar "$@" +fi diff --git a/gradle-plugins/cordformation/src/main/resources/net/corda/plugins/runnodes.bat b/gradle-plugins/cordformation/src/main/resources/net/corda/plugins/runnodes.bat new file mode 100644 index 0000000000..a6acf1f737 --- /dev/null +++ b/gradle-plugins/cordformation/src/main/resources/net/corda/plugins/runnodes.bat @@ -0,0 +1,8 @@ +@echo off + +REM Change to the directory of this script (%~dp0) +Pushd %~dp0 + +java -jar runnodes.jar %* + +Popd \ No newline at end of file diff --git a/gradle-plugins/cordformation/src/noderunner/kotlin/net/corda/plugins/NodeRunner.kt b/gradle-plugins/cordformation/src/noderunner/kotlin/net/corda/plugins/NodeRunner.kt new file mode 100644 index 0000000000..bb308a8191 --- /dev/null +++ b/gradle-plugins/cordformation/src/noderunner/kotlin/net/corda/plugins/NodeRunner.kt @@ -0,0 +1,151 @@ +package net.corda.plugins + +import java.awt.GraphicsEnvironment +import java.io.File +import java.nio.file.Files +import java.util.* + +private val HEADLESS_FLAG = "--headless" +private val CAPSULE_DEBUG_FLAG = "--capsule-debug" + +private val os by lazy { + val osName = System.getProperty("os.name", "generic").toLowerCase(Locale.ENGLISH) + if ("mac" in osName || "darwin" in osName) OS.MACOS + else if ("win" in osName) OS.WINDOWS + else OS.LINUX +} + +private enum class OS { MACOS, WINDOWS, LINUX } + +private object debugPortAlloc { + private var basePort = 5005 + internal fun next() = basePort++ +} + +private object monitoringPortAlloc { + private var basePort = 7005 + internal fun next() = basePort++ +} + +fun main(args: Array) { + val startedProcesses = mutableListOf() + val headless = GraphicsEnvironment.isHeadless() || args.contains(HEADLESS_FLAG) + val capsuleDebugMode = args.contains(CAPSULE_DEBUG_FLAG) + val workingDir = File(System.getProperty("user.dir")) + val javaArgs = args.filter { it != HEADLESS_FLAG && it != CAPSULE_DEBUG_FLAG } + val jvmArgs = if (capsuleDebugMode) listOf("-Dcapsule.log=verbose") else emptyList() + println("Starting nodes in $workingDir") + workingDir.listFiles { file -> file.isDirectory }.forEach { dir -> + listOf(NodeJarType, WebJarType).forEach { jarType -> + jarType.acceptDirAndStartProcess(dir, headless, javaArgs, jvmArgs)?.let { startedProcesses += it } + } + } + println("Started ${startedProcesses.size} processes") + println("Finished starting nodes") +} + +private abstract class JarType(private val jarName: String) { + internal abstract fun acceptNodeConf(nodeConf: File): Boolean + internal fun acceptDirAndStartProcess(dir: File, headless: Boolean, javaArgs: List, jvmArgs: List): Process? { + if (!File(dir, jarName).exists()) { + return null + } + if (!File(dir, "node.conf").let { it.exists() && acceptNodeConf(it) }) { + return null + } + val debugPort = debugPortAlloc.next() + val monitoringPort = monitoringPortAlloc.next() + println("Starting $jarName in $dir on debug port $debugPort") + val process = (if (headless) ::HeadlessJavaCommand else ::TerminalWindowJavaCommand)(jarName, dir, debugPort, monitoringPort, javaArgs, jvmArgs).start() + if (os == OS.MACOS) Thread.sleep(1000) + return process + } +} + +private object NodeJarType : JarType("corda.jar") { + override fun acceptNodeConf(nodeConf: File) = true +} + +private object WebJarType : JarType("corda-webserver.jar") { + // TODO: Add a webserver.conf, or use TypeSafe config instead of this hack + override fun acceptNodeConf(nodeConf: File) = Files.lines(nodeConf.toPath()).anyMatch { "webAddress" in it } +} + +private abstract class JavaCommand( + jarName: String, + internal val dir: File, + debugPort: Int?, + monitoringPort: Int?, + internal val nodeName: String, + init: MutableList.() -> Unit, args: List, + jvmArgs: List +) { + private val jolokiaJar by lazy { + File("$dir/drivers").listFiles { _, filename -> + filename.matches("jolokia-jvm-.*-agent\\.jar$".toRegex()) + }.first().name + } + + internal val command: List = mutableListOf().apply { + add(getJavaPath()) + addAll(jvmArgs) + add("-Dname=$nodeName") + val jvmArgs: MutableList = mutableListOf() + null != debugPort && jvmArgs.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$debugPort") + null != monitoringPort && jvmArgs.add("-javaagent:drivers/$jolokiaJar=port=$monitoringPort") + if (jvmArgs.isNotEmpty()) { + add("-Dcapsule.jvm.args=${jvmArgs.joinToString(separator = " ")}") + } + add("-jar") + add(jarName) + init() + addAll(args) + } + + internal abstract fun processBuilder(): ProcessBuilder + internal fun start() = processBuilder().directory(dir).start() + internal abstract fun getJavaPath(): String +} + +private class HeadlessJavaCommand(jarName: String, dir: File, debugPort: Int?, monitoringPort: Int?, args: List, jvmArgs: List) + : JavaCommand(jarName, dir, debugPort, monitoringPort, dir.name, { add("--no-local-shell") }, args, jvmArgs) { + override fun processBuilder() = ProcessBuilder(command).redirectError(File("error.$nodeName.log")).inheritIO() + override fun getJavaPath() = File(File(System.getProperty("java.home"), "bin"), "java").path +} + +private class TerminalWindowJavaCommand(jarName: String, dir: File, debugPort: Int?, monitoringPort: Int?, args: List, jvmArgs: List) + : JavaCommand(jarName, dir, debugPort, monitoringPort, "${dir.name}-$jarName", {}, args, jvmArgs) { + override fun processBuilder() = ProcessBuilder(when (os) { + OS.MACOS -> { + listOf("osascript", "-e", """tell app "Terminal" + activate + delay 0.5 + tell app "System Events" to tell process "Terminal" to keystroke "t" using command down + delay 0.5 + do script "bash -c 'cd \"$dir\" ; \"${command.joinToString("""\" \"""")}\" && exit'" in selected tab of the front window +end tell""") + } + OS.WINDOWS -> { + listOf("cmd", "/C", "start ${command.joinToString(" ") { windowsSpaceEscape(it) }}") + } + OS.LINUX -> { + // Start shell to keep window open unless java terminated normally or due to SIGTERM: + val command = "${unixCommand()}; [ $? -eq 0 -o $? -eq 143 ] || sh" + if (isTmux()) { + listOf("tmux", "new-window", "-n", nodeName, command) + } else { + listOf("xterm", "-T", nodeName, "-e", command) + } + } + }) + + private fun unixCommand() = command.map(::quotedFormOf).joinToString(" ") + override fun getJavaPath(): String = File(File(System.getProperty("java.home"), "bin"), "java").path + + // Replace below is to fix an issue with spaces in paths on Windows. + // Quoting the entire path does not work, only the space or directory within the path. + private fun windowsSpaceEscape(s:String) = s.replace(" ", "\" \"") +} + +private fun quotedFormOf(text: String) = "'${text.replace("'", "'\\''")}'" // Suitable for UNIX shells. +private fun isTmux() = System.getenv("TMUX")?.isNotEmpty() ?: false diff --git a/gradle-plugins/publish-utils/README.rst b/gradle-plugins/publish-utils/README.rst new file mode 100644 index 0000000000..d1657ee5fe --- /dev/null +++ b/gradle-plugins/publish-utils/README.rst @@ -0,0 +1,92 @@ +Publish Utils +============= + +Publishing utilities adds a couple of tasks to any project it is applied to that hide some boilerplate that would +otherwise be placed in the Cordapp template's build.gradle. + +There are two tasks exposed: `sourceJar` and `javadocJar` and both return a `FileCollection`. + +It is used within the `publishing` block of a build.gradle as such; + +.. code-block:: text + + // This will publish the sources, javadoc, and Java components to Maven. + // See the `maven-publish` plugin for more info: https://docs.gradle.org/current/userguide/publishing_maven.html + publishing { + publications { + jarAndSources(MavenPublication) { + from components.java + // The two lines below are the tasks added by this plugin. + artifact sourceJar + artifact javadocJar + } + } + } + +Bintray Publishing +------------------ + +For large multibuild projects it can be inconvenient to store the entire configuration for bintray and maven central +per project (with a bintray and publishing block with extended POM information). Publish utils can bring the number of +configuration blocks down to one in the ideal scenario. + +To use this plugin you must first apply it to both the root project and any project that will be published with + +.. code-block:: text + + apply plugin: 'net.corda.plugins.publish-utils' + +Next you must setup the general bintray configuration you wish to use project wide, for example: + +.. code-block:: text + + bintrayConfig { + user = + key = + repo = 'example repo' + org = 'example organisation' + licenses = ['a license'] + vcsUrl = 'https://example.com' + projectUrl = 'https://example.com' + gpgSign = true // Whether to GPG sign + gpgPassphrase = // Only required if gpgSign is true and your key is passworded + publications = ['example'] // a list of publications (see below) + license { + name = 'example' + url = 'https://example.com' + distribution = 'repo' + } + developer { + id = 'a developer id' + name = 'a developer name' + email = 'example@example.com' + } + } + +.. note:: You can currently only have one license and developer in the maven POM sections + +**Publications** + +This plugin assumes, by default, that publications match the name of the project. This means, by default, you can +just list the names of the projects you wish to publish (e.g. to publish `test:myapp` you need `publications = ['myapp']`. +If a project requires a different name you can configure it *per project* with the project configuration block. + +The project configuration block has the following structure: + +.. code-block:: text + + publish { + disableDefaultJar = false // set to true to disable the default JAR being created (e.g. when creating a fat JAR) + name 'non-default-project-name' // Always put this last because it causes configuration to happen + } + +**Artifacts** + +To add additional artifacts to the project you can use the default gradle `artifacts` block with the `publish` +configuration. For example: + + artifacts { + publish buildFatJar { + // You can configure this as a regular maven publication + } + } diff --git a/gradle-plugins/publish-utils/build.gradle b/gradle-plugins/publish-utils/build.gradle new file mode 100644 index 0000000000..da9c659498 --- /dev/null +++ b/gradle-plugins/publish-utils/build.gradle @@ -0,0 +1,109 @@ +apply plugin: 'groovy' +apply plugin: 'maven-publish' +apply plugin: 'com.jfrog.bintray' +apply plugin: 'com.jfrog.artifactory' + +// Used for bootstrapping project +buildscript { + Properties constants = new Properties() + file("../../constants.properties").withInputStream { constants.load(it) } + + ext { + gradle_plugins_version = constants.getProperty("gradlePluginsVersion") + artifactory_plugin_version = constants.getProperty('artifactoryPluginVersion') + } + + repositories { + jcenter() + } + + dependencies { + classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.4' + classpath "org.jfrog.buildinfo:build-info-extractor-gradle:$artifactory_plugin_version" + } +} + +version "$gradle_plugins_version" + +dependencies { + compile gradleApi() + compile localGroovy() +} + +repositories { + mavenCentral() +} + +task("sourceJar", type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource +} + +task("javadocJar", type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +bintray { + user = System.getenv('CORDA_BINTRAY_USER') + key = System.getenv('CORDA_BINTRAY_KEY') + publications = ['publishUtils'] + dryRun = false + pkg { + repo = 'corda' + name = 'publish-utils' + userOrg = 'r3' + licenses = ['Apache-2.0'] + + version { + gpg { + sign = true + passphrase = System.getenv('CORDA_BINTRAY_GPG_PASSPHRASE') + } + } + } +} + +publishing { + publications { + publishUtils(MavenPublication) { + from components.java + groupId 'net.corda.plugins' + artifactId 'publish-utils' + + artifact sourceJar + artifact javadocJar + + pom.withXml { + asNode().children().last() + { + resolveStrategy = Closure.DELEGATE_FIRST + name 'publish-utils' + description 'A small gradle plugin that adds a couple of convenience functions for publishing to Maven' + url 'https://github.com/corda/corda' + scm { + url 'https://github.com/corda/corda' + } + + licenses { + license { + name 'Apache-2.0' + url 'https://www.apache.org/licenses/LICENSE-2.0' + distribution 'repo' + } + } + + developers { + developer { + id 'R3' + name 'R3' + email 'dev@corda.net' + } + } + } + } + } + } +} + +// Aliasing the publishToMavenLocal for simplicity. +task(install, dependsOn: 'publishToMavenLocal') diff --git a/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/ProjectPublishExtension.groovy b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/ProjectPublishExtension.groovy new file mode 100644 index 0000000000..edb543fa51 --- /dev/null +++ b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/ProjectPublishExtension.groovy @@ -0,0 +1,39 @@ +package net.corda.plugins + +class ProjectPublishExtension { + private PublishTasks task + + void setPublishTask(PublishTasks task) { + this.task = task + } + + /** + * Use a different name from the current project name for publishing. + * Set this after all other settings that need to be configured + */ + void name(String name) { + task.setPublishName(name) + } + + /** + * Get the publishing name for this project. + */ + String name() { + return task.getPublishName() + } + + /** + * True when we do not want to publish default Java components + */ + Boolean disableDefaultJar = false + + /** + * True if publishing a WAR instead of a JAR. Forces disableDefaultJAR to "true" when true + */ + Boolean publishWar = false + + /** + * True if publishing sources to remote repositories + */ + Boolean publishSources = true +} \ No newline at end of file diff --git a/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/PublishTasks.groovy b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/PublishTasks.groovy new file mode 100644 index 0000000000..7112ee8117 --- /dev/null +++ b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/PublishTasks.groovy @@ -0,0 +1,161 @@ +package net.corda.plugins + +import org.gradle.api.* +import org.gradle.api.tasks.bundling.Jar +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.api.Project +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.maven.MavenPom +import net.corda.plugins.bintray.* + +/** + * A utility plugin that when applied will automatically create source and javadoc publishing tasks + * To apply this plugin you must also add 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.4' to your + * buildscript's classpath dependencies. + * + * To use this plugin you can add a new configuration block (extension) to your root build.gradle. See the fields + * in BintrayConfigExtension. + */ +class PublishTasks implements Plugin { + Project project + String publishName + ProjectPublishExtension publishConfig + + void apply(Project project) { + this.project = project + this.publishName = project.name + + createTasks() + createExtensions() + createConfigurations() + } + + void setPublishName(String publishName) { + project.logger.info("Changing publishing name from ${project.name} to ${publishName}") + this.publishName = publishName + checkAndConfigurePublishing() + } + + void checkAndConfigurePublishing() { + project.logger.info("Checking whether to publish $publishName") + def bintrayConfig = project.rootProject.extensions.findByType(BintrayConfigExtension.class) + if((bintrayConfig != null) && (bintrayConfig.publications) && (bintrayConfig.publications.findAll { it == publishName }.size() > 0)) { + configurePublishing(bintrayConfig) + } + } + + void configurePublishing(BintrayConfigExtension bintrayConfig) { + project.logger.info("Configuring bintray for ${publishName}") + configureMavenPublish(bintrayConfig) + configureBintray(bintrayConfig) + } + + void configureMavenPublish(BintrayConfigExtension bintrayConfig) { + project.apply([plugin: 'maven-publish']) + project.publishing.publications.create(publishName, MavenPublication) { + groupId project.group + artifactId publishName + + if (publishConfig.publishSources) { + artifact project.tasks.sourceJar + } + artifact project.tasks.javadocJar + + project.configurations.publish.artifacts.each { + project.logger.debug("Adding artifact: $it") + delegate.artifact it + } + + if (!publishConfig.disableDefaultJar && !publishConfig.publishWar) { + from project.components.java + } else if (publishConfig.publishWar) { + from project.components.web + } + + extendPomForMavenCentral(pom, bintrayConfig) + } + project.task("install", dependsOn: "publishToMavenLocal") + } + + // Maven central requires all of the below fields for this to be a valid POM + void extendPomForMavenCentral(MavenPom pom, BintrayConfigExtension config) { + pom.withXml { + asNode().children().last() + { + resolveStrategy = Closure.DELEGATE_FIRST + name publishName + description project.description + url config.projectUrl + scm { + url config.vcsUrl + } + + licenses { + license { + name config.license.name + url config.license.url + distribution config.license.url + } + } + + developers { + developer { + id config.developer.id + name config.developer.name + email config.developer.email + } + } + } + } + } + + void configureBintray(BintrayConfigExtension bintrayConfig) { + project.apply([plugin: 'com.jfrog.bintray']) + project.bintray { + user = bintrayConfig.user + key = bintrayConfig.key + publications = [ publishName ] + dryRun = bintrayConfig.dryRun ?: false + pkg { + repo = bintrayConfig.repo + name = publishName + userOrg = bintrayConfig.org + licenses = bintrayConfig.licenses + + version { + gpg { + sign = bintrayConfig.gpgSign ?: false + passphrase = bintrayConfig.gpgPassphrase + } + } + } + } + } + + void createTasks() { + if(project.hasProperty('classes')) { + project.task("sourceJar", type: Jar, dependsOn: project.classes) { + classifier = 'sources' + from project.sourceSets.main.allSource + } + } + + if(project.hasProperty('javadoc')) { + project.task("javadocJar", type: Jar, dependsOn: project.javadoc) { + classifier = 'javadoc' + from project.javadoc.destinationDir + } + } + } + + void createExtensions() { + if(project == project.rootProject) { + project.extensions.create("bintrayConfig", BintrayConfigExtension) + } + publishConfig = project.extensions.create("publish", ProjectPublishExtension) + publishConfig.setPublishTask(this) + } + + void createConfigurations() { + project.configurations.create("publish") + } +} diff --git a/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/BintrayConfigExtension.groovy b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/BintrayConfigExtension.groovy new file mode 100644 index 0000000000..1a1c4e49e5 --- /dev/null +++ b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/BintrayConfigExtension.groovy @@ -0,0 +1,70 @@ +package net.corda.plugins.bintray + +import org.gradle.util.ConfigureUtil + +class BintrayConfigExtension { + /** + * Bintray username + */ + String user + /** + * Bintray access key + */ + String key + /** + * Bintray repository + */ + String repo + /** + * Bintray organisation + */ + String org + /** + * Licenses for packages uploaded by this configuration + */ + String[] licenses + /** + * Whether to sign packages uploaded by this configuration + */ + Boolean gpgSign + /** + * The passphrase for the key used to sign releases. + */ + String gpgPassphrase + /** + * VCS URL + */ + String vcsUrl + /** + * Project URL + */ + String projectUrl + /** + * The publications that will be uploaded as a part of this configuration. These must match both the name on + * bintray and the gradle module name. ie; it must be "some-package" as a gradle sub-module (root project not + * supported, this extension is to improve multi-build bintray uploads). The publication must also be called + * "some-package". Only one publication can be uploaded per module (a bintray plugin restriction(. + * If any of these conditions are not met your package will not be uploaded. + */ + String[] publications + /** + * Whether to test the publication without uploading to bintray. + */ + Boolean dryRun + /** + * The license this project will use (currently limited to one) + */ + License license = new License() + /** + * The developer of this project (currently limited to one) + */ + Developer developer = new Developer() + + void license(Closure closure) { + ConfigureUtil.configure(closure, license) + } + + void developer(Closure closure) { + ConfigureUtil.configure(closure, developer) + } +} \ No newline at end of file diff --git a/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/Developer.groovy b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/Developer.groovy new file mode 100644 index 0000000000..1d66f68c7d --- /dev/null +++ b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/Developer.groovy @@ -0,0 +1,16 @@ +package net.corda.plugins.bintray + +class Developer { + /** + * A unique identifier the developer (eg; organisation ID) + */ + String id + /** + * The full name of the developer + */ + String name + /** + * An email address for contacting the developer + */ + String email +} \ No newline at end of file diff --git a/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/License.groovy b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/License.groovy new file mode 100644 index 0000000000..1d06867bcf --- /dev/null +++ b/gradle-plugins/publish-utils/src/main/groovy/net/corda/plugins/bintray/License.groovy @@ -0,0 +1,16 @@ +package net.corda.plugins.bintray + +class License { + /** + * The name of license (eg; Apache 2.0) + */ + String name + /** + * URL to the full license file + */ + String url + /** + * The distribution level this license corresponds to (eg: repo) + */ + String distribution +} \ No newline at end of file diff --git a/gradle-plugins/publish-utils/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.publish-utils.properties b/gradle-plugins/publish-utils/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.publish-utils.properties new file mode 100644 index 0000000000..b680f7d301 --- /dev/null +++ b/gradle-plugins/publish-utils/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.publish-utils.properties @@ -0,0 +1 @@ +implementation-class=net.corda.plugins.PublishTasks diff --git a/gradle-plugins/quasar-utils/README.rst b/gradle-plugins/quasar-utils/README.rst new file mode 100644 index 0000000000..481d8ec66b --- /dev/null +++ b/gradle-plugins/quasar-utils/README.rst @@ -0,0 +1,4 @@ +Quasar Utils +============ + +Quasar utilities adds several tasks and configuration that provide a default Quasar setup and removes some boilerplate. \ No newline at end of file diff --git a/gradle-plugins/quasar-utils/build.gradle b/gradle-plugins/quasar-utils/build.gradle new file mode 100644 index 0000000000..8f9eca30f2 --- /dev/null +++ b/gradle-plugins/quasar-utils/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'groovy' +apply plugin: 'maven-publish' +apply plugin: 'net.corda.plugins.publish-utils' +apply plugin: 'com.jfrog.artifactory' + +description 'A small gradle plugin for adding some basic Quasar tasks and configurations to reduce build.gradle bloat.' + +repositories { + mavenCentral() +} + +dependencies { + compile gradleApi() + compile localGroovy() +} + +publish { + name project.name +} diff --git a/gradle-plugins/quasar-utils/src/main/groovy/net/corda/plugins/QuasarPlugin.groovy b/gradle-plugins/quasar-utils/src/main/groovy/net/corda/plugins/QuasarPlugin.groovy new file mode 100644 index 0000000000..6a4ffaf25a --- /dev/null +++ b/gradle-plugins/quasar-utils/src/main/groovy/net/corda/plugins/QuasarPlugin.groovy @@ -0,0 +1,28 @@ +package net.corda.plugins + +import org.gradle.api.Project +import org.gradle.api.Plugin +import org.gradle.api.tasks.testing.Test +import org.gradle.api.tasks.JavaExec + +/** + * QuasarPlugin creates a "quasar" configuration and adds quasar as a dependency. + */ +class QuasarPlugin implements Plugin { + void apply(Project project) { + project.configurations.create("quasar") +// To add a local .jar dependency: +// project.dependencies.add("quasar", project.files("${project.rootProject.projectDir}/lib/quasar.jar")) + project.dependencies.add("quasar", "co.paralleluniverse:quasar-core:${project.rootProject.ext.quasar_version}:jdk8@jar") + project.dependencies.add("runtime", project.configurations.getByName("quasar")) + + project.tasks.withType(Test) { + jvmArgs "-javaagent:${project.configurations.quasar.singleFile}" + jvmArgs "-Dco.paralleluniverse.fibers.verifyInstrumentation" + } + project.tasks.withType(JavaExec) { + jvmArgs "-javaagent:${project.configurations.quasar.singleFile}" + jvmArgs "-Dco.paralleluniverse.fibers.verifyInstrumentation" + } + } +} diff --git a/gradle-plugins/quasar-utils/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.quasar-utils.properties b/gradle-plugins/quasar-utils/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.quasar-utils.properties new file mode 100644 index 0000000000..fb7042bb42 --- /dev/null +++ b/gradle-plugins/quasar-utils/src/main/resources/META-INF/gradle-plugins/net.corda.plugins.quasar-utils.properties @@ -0,0 +1 @@ +implementation-class=net.corda.plugins.QuasarPlugin diff --git a/gradle-plugins/settings.gradle b/gradle-plugins/settings.gradle new file mode 100644 index 0000000000..995cd8c899 --- /dev/null +++ b/gradle-plugins/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'corda-gradle-plugins' +include 'publish-utils' +include 'quasar-utils' +include 'cordformation' +include 'cordform-common' +include 'api-scanner' +include 'cordapp' \ No newline at end of file