diff --git a/changelog/@unreleased/pr-1944.v2.yml b/changelog/@unreleased/pr-1944.v2.yml new file mode 100644 index 000000000..25dcf5f5e --- /dev/null +++ b/changelog/@unreleased/pr-1944.v2.yml @@ -0,0 +1,5 @@ +type: improvement +improvement: + description: Plumb Add-Exports through to execution and annotation processing sites + links: + - https://github.com/palantir/gradle-baseline/pull/1944 diff --git a/gradle-baseline-java/src/main/groovy/com/palantir/baseline/extensions/BaselineModuleJvmArgsExtension.java b/gradle-baseline-java/src/main/groovy/com/palantir/baseline/extensions/BaselineModuleJvmArgsExtension.java new file mode 100644 index 000000000..cac1b64f3 --- /dev/null +++ b/gradle-baseline-java/src/main/groovy/com/palantir/baseline/extensions/BaselineModuleJvmArgsExtension.java @@ -0,0 +1,73 @@ +/* + * (c) Copyright 2019 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.baseline.extensions; + +import com.google.common.collect.ImmutableSet; +import javax.inject.Inject; +import org.gradle.api.Project; +import org.gradle.api.provider.SetProperty; + +/** + * Extension to configure {@code --add-exports [VALUE]=ALL-UNNAMED} for the current module. + */ +public class BaselineModuleJvmArgsExtension { + + private final SetProperty exports; + + @Inject + public BaselineModuleJvmArgsExtension(Project project) { + exports = project.getObjects().setProperty(String.class); + } + + /** + * Property describing all export values for this module. + * Exports take the form {@code MODULE_NAME/PACKAGE_NAME}, so to represent + * {@code --add-exports java.management/sun.management=ALL-UNNAMED} one would add the value + * {@code java.management/sun.management}. + */ + public final SetProperty exports() { + return exports; + } + + public final void setExports(String... input) { + ImmutableSet immutableDeduplicatedCopy = ImmutableSet.copyOf(input); + for (String export : immutableDeduplicatedCopy) { + validateExport(export); + } + exports.set(immutableDeduplicatedCopy); + } + + private static void validateExport(String export) { + if (export.contains("=")) { + throw new IllegalArgumentException(String.format( + "Export '%s' must not contain an '=', e.g. 'java.management/sun.management'. " + + "Each export implies a '=ALL-UNNAMED' suffix", + export)); + } + if (export.contains(" ")) { + throw new IllegalArgumentException(String.format("Export '%s' must not contain whitespace", export)); + } + int firstSlash = export.indexOf('/'); + int lastSlash = export.lastIndexOf('/'); + if (firstSlash != lastSlash || firstSlash < 0) { + throw new IllegalArgumentException(String.format( + "Export '%s' must contain both a module name and package " + + "name separated by a single slash, e.g. 'java.management/sun.management'", + export)); + } + } +} diff --git a/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/Baseline.java b/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/Baseline.java index 91caf600c..260d73475 100644 --- a/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/Baseline.java +++ b/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/Baseline.java @@ -60,6 +60,7 @@ public void apply(Project project) { proj.getPluginManager().apply(BaselineJavaCompilerDiagnostics.class); proj.getPluginManager().apply(BaselineJavaParameters.class); proj.getPluginManager().apply(BaselineImmutables.class); + proj.getPluginManager().apply(BaselineModuleJvmArgs.class); }); } } diff --git a/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/BaselineModuleJvmArgs.java b/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/BaselineModuleJvmArgs.java new file mode 100644 index 000000000..36ae5732c --- /dev/null +++ b/gradle-baseline-java/src/main/groovy/com/palantir/baseline/plugins/BaselineModuleJvmArgs.java @@ -0,0 +1,215 @@ +/* + * (c) Copyright 2021 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.baseline.plugins; + +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.palantir.baseline.extensions.BaselineModuleJvmArgsExtension; +import java.io.IOException; +import java.util.Set; +import java.util.jar.JarFile; +import java.util.stream.Stream; +import org.gradle.api.Action; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; +import org.gradle.api.java.archives.Manifest; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.compile.JavaCompile; +import org.gradle.api.tasks.testing.Test; +import org.gradle.jvm.tasks.Jar; +import org.gradle.process.CommandLineArgumentProvider; + +/** + * This plugin reuses the {@code Add-Exports} manifest entry defined in + * JEP-261 to propagate and collect required exports + * from transitive dependencies, and applies them to compilation (for annotation processors) and + * execution (tests, javaExec, etc) for runtime dependencies. + */ +public final class BaselineModuleJvmArgs implements Plugin { + + private static final String EXTENSION_NAME = "moduleJvmArgs"; + private static final String ADD_EXPORTS_ATTRIBUTE = "Add-Exports"; + + private static final Splitter EXPORT_SPLITTER = + Splitter.on(' ').trimResults().omitEmptyStrings(); + + @Override + public void apply(Project project) { + project.getPluginManager().withPlugin("java", unused -> { + BaselineModuleJvmArgsExtension extension = + project.getExtensions().create(EXTENSION_NAME, BaselineModuleJvmArgsExtension.class, project); + + // javac isn't provided `--add-exports` args for the time being due to + // https://github.com/gradle/gradle/issues/18824 + if (project.hasProperty("add.exports.to.javac")) { + project.getExtensions().getByType(SourceSetContainer.class).configureEach(sourceSet -> { + JavaCompile javaCompile = project.getTasks() + .named(sourceSet.getCompileJavaTaskName(), JavaCompile.class) + .get(); + javaCompile + .getOptions() + .getCompilerArgumentProviders() + // Use an anonymous class because tasks with lambda inputs cannot be cached + .add(new CommandLineArgumentProvider() { + @Override + public Iterable asArguments() { + // Annotation processors are executed at compile time + ImmutableList arguments = + collectAnnotationProcessorExports(project, extension, sourceSet); + project.getLogger() + .debug( + "BaselineModuleJvmArgs compiling {} on {} with exports: {}", + javaCompile.getName(), + project, + arguments); + return arguments; + } + }); + }); + } + + project.getTasks().withType(Test.class, new Action() { + + @Override + public void execute(Test test) { + test.getJvmArgumentProviders().add(new CommandLineArgumentProvider() { + + @Override + public Iterable asArguments() { + ImmutableList arguments = + collectClasspathExports(project, extension, test.getClasspath()); + project.getLogger() + .debug( + "BaselineModuleJvmArgs executing {} on {} with exports: {}", + test.getName(), + project, + arguments); + return arguments; + } + }); + } + }); + + project.getTasks().withType(JavaExec.class, new Action() { + + @Override + public void execute(JavaExec javaExec) { + javaExec.getJvmArgumentProviders().add(new CommandLineArgumentProvider() { + + @Override + public Iterable asArguments() { + ImmutableList arguments = + collectClasspathExports(project, extension, javaExec.getClasspath()); + project.getLogger() + .debug( + "BaselineModuleJvmArgs executing {} on {} with exports: {}", + javaExec.getName(), + project, + arguments); + return arguments; + } + }); + } + }); + + project.getTasks().withType(Jar.class, new Action() { + @Override + public void execute(Jar jar) { + jar.doFirst(new Action() { + @Override + public void execute(Task task) { + jar.manifest(new Action() { + @Override + public void execute(Manifest manifest) { + // Only locally defined exports are applied to jars + Set exports = extension.exports().get(); + if (!exports.isEmpty()) { + project.getLogger() + .debug( + "BaselineModuleJvmArgs adding " + + "manifest attributes to {} in {}: {}", + jar.getName(), + project, + exports); + manifest.attributes( + ImmutableMap.of(ADD_EXPORTS_ATTRIBUTE, String.join(" ", exports))); + } else { + project.getLogger() + .debug( + "BaselineModuleJvmArgs not adding " + + "manifest attributes to {} in {}", + jar.getName(), + project); + } + } + }); + } + }); + } + }); + }); + } + + private static ImmutableList collectAnnotationProcessorExports( + Project project, BaselineModuleJvmArgsExtension extension, SourceSet sourceSet) { + return collectClasspathExports( + project, + extension, + project.getConfigurations().getByName(sourceSet.getAnnotationProcessorConfigurationName())); + } + + private static ImmutableList collectClasspathExports( + Project project, BaselineModuleJvmArgsExtension extension, FileCollection classpath) { + return Stream.concat( + classpath.getFiles().stream().flatMap(file -> { + try { + if (file.getName().endsWith(".jar") && file.isFile()) { + try (JarFile jar = new JarFile(file)) { + String value = jar.getManifest() + .getMainAttributes() + .getValue(ADD_EXPORTS_ATTRIBUTE); + if (Strings.isNullOrEmpty(value)) { + return Stream.empty(); + } + project.getLogger() + .debug( + "Found manifest entry {}: {} in jar {}", + ADD_EXPORTS_ATTRIBUTE, + value, + file); + return EXPORT_SPLITTER.splitToStream(value); + } + } + return Stream.empty(); + } catch (IOException e) { + project.getLogger().warn("Failed to check jar {} for manifest attributes", file, e); + return Stream.empty(); + } + }), + extension.exports().get().stream()) + .distinct() + .sorted() + .flatMap(modulePackagePair -> Stream.of("--add-exports", modulePackagePair + "=ALL-UNNAMED")) + .collect(ImmutableList.toImmutableList()); + } +} diff --git a/gradle-baseline-java/src/main/resources/META-INF/gradle-plugins/com.palantir.baseline-module-jvm-args.properties b/gradle-baseline-java/src/main/resources/META-INF/gradle-plugins/com.palantir.baseline-module-jvm-args.properties new file mode 100644 index 000000000..45ff1303c --- /dev/null +++ b/gradle-baseline-java/src/main/resources/META-INF/gradle-plugins/com.palantir.baseline-module-jvm-args.properties @@ -0,0 +1 @@ +implementation-class=com.palantir.baseline.plugins.BaselineModuleJvmArgs diff --git a/gradle-baseline-java/src/test/groovy/com/palantir/baseline/BaselineModuleJvmArgsIntegrationTest.groovy b/gradle-baseline-java/src/test/groovy/com/palantir/baseline/BaselineModuleJvmArgsIntegrationTest.groovy new file mode 100644 index 000000000..edc1a8921 --- /dev/null +++ b/gradle-baseline-java/src/test/groovy/com/palantir/baseline/BaselineModuleJvmArgsIntegrationTest.groovy @@ -0,0 +1,204 @@ +/* + * (c) Copyright 2021 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.baseline + +import com.google.common.collect.MoreCollectors +import nebula.test.IntegrationSpec +import nebula.test.functional.ExecutionResult + +import java.util.jar.Attributes +import java.util.jar.JarFile +import java.util.jar.JarOutputStream +import java.util.jar.Manifest + +class BaselineModuleJvmArgsIntegrationTest extends IntegrationSpec { + + def standardBuildFile = ''' + plugins { + id 'java-library' + id 'application' + } + + apply plugin: 'com.palantir.baseline-java-versions' + apply plugin: 'com.palantir.baseline-module-jvm-args' + + javaVersions { + libraryTarget = 11 + runtime = 17 + } + + repositories { + mavenCentral() + } + '''.stripIndent(true) + + def setup() { + setFork(true) + buildFile << standardBuildFile + } + + def 'Runs with locally defined exports'() { + when: + buildFile << ''' + application { + mainClass = 'com.Example' + } + + moduleJvmArgs { + exports = ['java.management/sun.management'] + } + '''.stripIndent(true) + writeJavaSourceFile(''' + package com; + public class Example { + public static void main(String[] args) { + System.out.println(String.join( + " ", + java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments())); + } + } + '''.stripIndent(true)) + + then: + ExecutionResult result = runTasksSuccessfully('run') + // Gradle appears to normalize args, joining '--add-exports java.management/sun.management=ALL-UNNAMED' + // with an equals. + result.standardOutput.contains('--add-exports=java.management/sun.management=ALL-UNNAMED') + } + + def 'Adds locally defined exports to the jar manifest'() { + when: + buildFile << ''' + application { + mainClass = 'com.Example' + } + + moduleJvmArgs { + exports = ['java.management/sun.management'] + } + '''.stripIndent(true) + writeJavaSourceFile(''' + package com; + public class Example { + public static void main(String[] args) { + System.out.println(String.join( + " ", + java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments())); + } + } + '''.stripIndent(true)) + + then: + runTasksSuccessfully('jar') + JarFile jarFile = Arrays.stream(directory("build/libs").listFiles()) + .filter(file -> file.name.endsWith(".jar")) + .map(JarFile::new) + .collect(MoreCollectors.onlyElement()) + String manifestValue = jarFile.getManifest().getMainAttributes().getValue('Add-Exports') + manifestValue == 'java.management/sun.management' + } + + def 'Executes with externally defined exports'() { + when: + Manifest manifest = new Manifest() + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0") + manifest.getMainAttributes().putValue('Add-Exports', 'java.management/sun.management') + File testJar = new File(getProjectDir(),"test.jar"); + testJar.withOutputStream { fos -> + new JarOutputStream(fos, manifest).close() + } + buildFile << ''' + application { + mainClass = 'com.Example' + } + dependencies { + implementation files('test.jar') + } + '''.stripIndent(true) + writeJavaSourceFile(''' + package com; + public class Example { + public static void main(String[] args) { + System.out.println(String.join( + " ", + java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments())); + } + } + '''.stripIndent(true)) + + then: + ExecutionResult result = runTasksSuccessfully('run') + // Gradle appears to normalize args, joining '--add-exports java.management/sun.management=ALL-UNNAMED' + // with an equals. + result.standardOutput.contains('--add-exports=java.management/sun.management=ALL-UNNAMED') + } + + def 'Does not add externally defined exports to the jar manifest'() { + when: + Manifest manifest = new Manifest() + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0") + manifest.getMainAttributes().putValue('Add-Exports', 'java.management/sun.management') + File testJar = new File(getProjectDir(),"test.jar"); + testJar.withOutputStream { fos -> + new JarOutputStream(fos, manifest).close() + } + buildFile << ''' + application { + mainClass = 'com.Example' + } + dependencies { + implementation files('test.jar') + } + '''.stripIndent(true) + writeJavaSourceFile(''' + package com; + public class Example { + public static void main(String[] args) { + System.out.println(String.join( + " ", + java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments())); + } + } + '''.stripIndent(true)) + + then: + runTasksSuccessfully('jar') + JarFile jarFile = Arrays.stream(directory("build/libs").listFiles()) + .filter(file -> file.name.endsWith(".jar")) + .map(JarFile::new) + .collect(MoreCollectors.onlyElement()) + String manifestValue = jarFile.getManifest().getMainAttributes().getValue('Add-Exports') + manifestValue == null + } + + def 'Validates exports'() { + when: + buildFile << ''' + application { + mainClass = 'com.Example' + } + + moduleJvmArgs { + exports = ['java.management'] + } + '''.stripIndent(true) + + then: + ExecutionResult result = runTasksWithFailure('jar') + result.standardError.contains('separated by a single slash') + } +}