From aec29bff2a2c7df2e45763048c35a30d7d975606 Mon Sep 17 00:00:00 2001 From: Ryan Dens Date: Sat, 17 Oct 2026 00:20:07 -0700 Subject: [PATCH 1/7] Add ability to pass options to javaagents (#50) Support the `-javaagent:=` form of the flag by introducing a `javaagent { }` extension with an `agentOptions` map keyed by dependency coordinate (`group:name` for module dependencies, project path for project dependencies). Keying by coordinate keeps options independent of the resolved jar file name so they survive version bumps. A shared `AgentOptionsResolver` resolves the coordinate-keyed options against the configuration's resolved artifacts into a `fileName -> options` map, which is threaded into all four contexts that build a `-javaagent:` argument: the application `run` task, the `test` task, application distribution start scripts, and jib container entrypoints. Agents without a matching entry are attached with no suffix, preserving existing behavior. Per the design discussion on the issue, options are a single project-level value shared across contexts; per-context overrides are left as a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FBTxQkVXBjJoBSfKPWyY3K --- README.md | 32 +++++++ .../jib/JibExtensionConfiguration.java | 14 +++ .../javaagent/jib/JavaagentJibExtension.kt | 9 +- .../JavaagentJibExtensionFunctionalTest.kt | 38 ++++++++ .../javaagent/jib/JavaagentJibPlugin.kt | 7 ++ .../JavaagentPluginFunctionalTest.kt | 87 +++++++++++++++++++ .../javaagent/AgentOptionsResolver.java | 83 ++++++++++++++++++ .../javaagent/JavaForkOptionsConfigurer.java | 12 ++- .../JavaagentApplicationDistributionPlugin.kt | 9 +- .../JavaagentApplicationRunPlugin.kt | 10 ++- .../JavaagentAwareStartScriptGenerator.kt | 17 +++- .../ryandens/javaagent/JavaagentBasePlugin.kt | 6 ++ .../ryandens/javaagent/JavaagentExtension.kt | 34 ++++++++ .../ryandens/javaagent/JavaagentTestPlugin.kt | 6 ++ .../main/java/com/ryandens/SimpleAgent.java | 3 + 15 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java create mode 100644 plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt diff --git a/README.md b/README.md index a5700528..5033253c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,38 @@ by the `javaagent` Gradle configuration! In addition, the distributions created ApplicationPlugin tasks include the javaagent JAR in the archive's dependency directory. Finally, the distribution start scripts have been modified to automatically attach the agent via the command line `-javaagent` flag. +## Passing options to javaagents + +Some javaagents accept options via the `-javaagent:=` form of the flag (see the +[`java.lang.instrument` documentation](https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/package-summary.html#starting-an-agent-from-the-command-line-interface-heading)). +For example, the [Prometheus JMX Exporter](https://github.com/prometheus/jmx_exporter) agent is configured as +`-javaagent:jmx_prometheus_javaagent.jar=12345:config.yaml`. + +Use the `javaagent` extension to associate options with an agent. Options are keyed by the dependency +coordinate you declared, so they are independent of the resolved jar file name and survive version bumps: + +* module dependencies are keyed by `group:name` (the version is intentionally excluded) +* project dependencies are keyed by their project path, e.g. `:my-agent` + +```kotlin +plugins { + application + id("com.ryandens.javaagent-application") version "0.11.0" +} + +dependencies { + javaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") +} + +javaagent { + agentOptions.put("io.prometheus.jmx:jmx_prometheus_javaagent", "12345:config.yaml") +} +``` + +The same options are applied consistently across every context this plugin configures: the application `run` +task, the application distribution start scripts, the `test` task, and jib container entrypoints. Agents without +a matching entry are attached without an options suffix, exactly as before. + ## Jib integration [Jib](https://github.com/GoogleContainerTools/jib) is a build tool for containerizing Java applications without a Docker diff --git a/jib-common/src/main/java/com/ryandens/javaagent/jib/JibExtensionConfiguration.java b/jib-common/src/main/java/com/ryandens/javaagent/jib/JibExtensionConfiguration.java index 6f54e2d2..6bc4eb9d 100644 --- a/jib-common/src/main/java/com/ryandens/javaagent/jib/JibExtensionConfiguration.java +++ b/jib-common/src/main/java/com/ryandens/javaagent/jib/JibExtensionConfiguration.java @@ -5,6 +5,7 @@ import org.gradle.api.Project; import org.gradle.api.model.ObjectFactory; import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; import org.gradle.api.tasks.Input; /** @@ -18,6 +19,8 @@ public class JibExtensionConfiguration { private final ListProperty javaagentFiles; + private final MapProperty agentOptions; + /** * Instantiated by Jib's plugin extension mechanism * @@ -40,6 +43,7 @@ public JibExtensionConfiguration(final Project project) { */ public JibExtensionConfiguration(final ObjectFactory objectFactory) { javaagentFiles = objectFactory.listProperty(File.class); + agentOptions = objectFactory.mapProperty(String.class, String.class); } /** @@ -52,4 +56,14 @@ public JibExtensionConfiguration(final ObjectFactory objectFactory) { ListProperty getJavaagentFiles() { return javaagentFiles; } + + /** + * Returns the agent options keyed by agent file name. When an agent's file name has an entry + * here, the corresponding value is appended to its {@code -javaagent} flag as {@code =}. + * Package-private: intended to be accessed only by {@link JavaagentJibExtension}. + */ + @Input + MapProperty getAgentOptions() { + return agentOptions; + } } diff --git a/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt b/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt index ea8adba2..0f531a19 100644 --- a/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt +++ b/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt @@ -46,12 +46,19 @@ class JavaagentJibExtension : } val localAgentPaths = extraConfig.get().javaagentFiles.get() + val agentOptions = extraConfig.get().agentOptions.get() val planBuilder = buildPlan.toBuilder() val newEntrypoint = buildList { addAll(entrypoint) - addAll(1, localAgentPaths.map { localAgentPath -> "-javaagent:/opt/jib-agents/${localAgentPath.name}" }) + addAll( + 1, + localAgentPaths.map { localAgentPath -> + val suffix = agentOptions[localAgentPath.name]?.let { "=$it" } ?: "" + "-javaagent:/opt/jib-agents/${localAgentPath.name}$suffix" + }, + ) } val javaagentFileEntries = localAgentPaths.map { localAgentPath -> diff --git a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt index c8aa543e..dd29bfb5 100644 --- a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt +++ b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt @@ -59,6 +59,41 @@ class JavaagentJibExtensionFunctionalTest { } } + @Test fun `can pass agent options to jib entrypoint`() { + val dependencies = """ + javaagent project(':simple-agent') + runtimeOnly 'commons-lang:commons-lang:2.6' + """ + + val result = + createAndBuildJavaagentProject( + dependencies, + listOf("jibBuildTar"), + extraBuildScript = + """ + javaagent { + agentOptions.put(':simple-agent', '12345:config.yaml') + } + """, + ) + + assertTrue(result.output.contains("Running extension: com.ryandens.javaagent.jib.JavaagentJibExtension")) + assertTrue(File(functionalTestDir, JIB_IMAGE).exists()) + + FileInputStream(File(functionalTestDir, JIB_IMAGE)).use { fis -> + ArchiveStreamFactory().createArchiveInputStream>(ArchiveStreamFactory.TAR, fis).use { ais -> + var entry = ais.nextEntry + while (entry != null) { + if ("config.json" == entry.name) { + val json = ais.readBytes().toString(Charsets.UTF_8) + assertTrue(json.contains("-javaagent:/opt/jib-agents/simple-agent.jar=12345:config.yaml")) + } + entry = ais.nextEntry + } + } + } + } + @Test fun `works even without any javaagent dependencies`() { val dependencies = """ runtimeOnly 'commons-lang:commons-lang:2.6' @@ -89,6 +124,7 @@ class JavaagentJibExtensionFunctionalTest { private fun createAndBuildJavaagentProject( dependencies: String, buildArgs: List, + extraBuildScript: String = "", ): BuildResult { val helloWorldDir = File(functionalTestDir, "hello-world") File( @@ -150,6 +186,8 @@ class JavaagentJibExtensionFunctionalTest { image = "javaagent-hello-world" } } + + $extraBuildScript """, ) diff --git a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt index e11944dd..51e448e2 100644 --- a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt +++ b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt @@ -1,7 +1,9 @@ package com.ryandens.javaagent.jib import com.google.cloud.tools.jib.gradle.JibExtension +import com.ryandens.javaagent.AgentOptionsResolver import com.ryandens.javaagent.JavaagentBasePlugin +import com.ryandens.javaagent.JavaagentExtension import org.gradle.api.Action import org.gradle.api.Plugin import org.gradle.api.Project @@ -21,6 +23,10 @@ class JavaagentJibPlugin : Plugin { val javaagentConfiguration = project.configurations.named(JavaagentBasePlugin.CONFIGURATION_NAME) + val javaagentExtension = project.extensions.getByType(JavaagentExtension::class.java) + val optionsByFileName = + AgentOptionsResolver.optionsByFileName(javaagentConfiguration.get(), javaagentExtension.agentOptions) + val destinationDirectory = project.tasks .named( @@ -44,6 +50,7 @@ class JavaagentJibPlugin : Plugin { .toList() }, ) + extensionConfiguration.agentOptions.set(optionsByFileName) }, ) } diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index d0bc56ce..dde977b5 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -211,6 +211,90 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X ) } + @Test fun `can pass agent options to application run task`() { + val dependencies = """ + javaagent project(':simple-agent') + """ + + createJavaagentProject( + dependencies, + extraBuildScript = + """ + javaagent { + agentOptions.put(':simple-agent', '12345:config.yaml') + } + """, + ) + val result = runBuild(listOf("assemble", "run")) + + assertTrue(result.output.contains("Hello World!")) + assertTrue(result.output.contains("Hello from my simple agent!")) + // the simple agent echoes the agentArgs it was launched with (the `=` portion) + assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) + } + + @Test fun `passes agent options only to the agent they are keyed to`() { + // Two agents declared, but options only keyed to the project agent. The other agent must not receive them. + val otelVersion = "2.29.0" + val dependencies = """ + javaagent project(':simple-agent') + javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:$otelVersion' + """ + + createJavaagentProject( + dependencies, + extraBuildScript = + """ + javaagent { + agentOptions.put(':simple-agent', '12345:config.yaml') + } + """, + ) + val result = runBuild(listOf("assemble", "run")) + + assertTrue(result.output.contains("Hello World!")) + assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) + // the OpenTelemetry agent still installs; it simply receives no options + assertTrue( + result.output.contains("io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: $otelVersion"), + ) + } + + @Test fun `can pass agent options to application distribution start script`() { + val dependencies = """ + javaagent project(':simple-agent') + """ + + createJavaagentProject( + dependencies, + extraBuildScript = + """ + javaagent { + agentOptions.put(':simple-agent', '12345:config.yaml') + } + """, + ) + val result = runBuild(listOf("--configuration-cache", "build", "installDist", "execStartScript")) + + val applicationDistributionScript = + File(functionalTestDir, "hello-world${File.separator}build${File.separator}scripts${File.separator}hello-world$scriptExtension") + if (isWindows) { + val expectedWindowsDefaultJvmOpts = + """set DEFAULT_JVM_OPTS="-javaagent:%APP_HOME%\agent-libs\simple-agent.jar=12345:config.yaml" "-Xmx256m"""" + assertTrue(applicationDistributionScript.readText().contains(expectedWindowsDefaultJvmOpts)) + } else { + // The option string rides inside the agent's own backslash-escaped quoted token -- see issue #95. + val expectedDefaultJavaOpts = + """ +DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar=12345:config.yaml\" \"-Xmx256m\"" +""" + assertTrue(applicationDistributionScript.readText().contains(expectedDefaultJavaOpts)) + } + + // Verify the option was actually applied at runtime + assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) + } + @Test fun `can handle upgrade of agent with build cache`() { createJavaagentProject( """ @@ -266,6 +350,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X private fun createJavaagentProject( dependencies: String, applicationDefaultJvmArgs: String = "['-Xmx256m']", + extraBuildScript: String = "", ) { val helloWorldDir = File(functionalTestDir, "hello-world") Paths.get("src", "functionalTest", "resources", "hello-world-project").toFile().copyRecursively(helloWorldDir) @@ -344,6 +429,8 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } + + $extraBuildScript """, ) } diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java new file mode 100644 index 00000000..5f1149eb --- /dev/null +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -0,0 +1,83 @@ +package com.ryandens.javaagent; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.provider.Provider; + +/** + * Resolves the {@link JavaagentExtension#getAgentOptions() agent options} declared by coordinate + * into a map keyed by the resolved artifact file name, which is the join key every {@code + * -javaagent:} argument site can compute (the run/test sites hold the {@link File}, the + * distribution and jib sites use {@code file.getName()}). + * + *

This class is written in Java for the same reason as {@link JavaForkOptionsConfigurer}: to + * avoid the Gradle + * task input serialization limitation with Kotlin lambdas. + */ +public final class AgentOptionsResolver { + + /** Prevent instantiation for utility class. */ + private AgentOptionsResolver() {} + + /** + * Builds a {@code fileName -> options} map from the resolved artifacts of the provided + * configuration and the coordinate-keyed options declared on the {@link JavaagentExtension}. Only + * agents that have options declared appear in the resulting map. + * + *

The returned {@link Provider} is lazy and configuration-cache safe: it reads the + * configuration's resolved artifacts and the options map without touching {@code Project} at + * execution time. + * + * @param configuration the resolvable javaagent configuration whose artifacts should be matched + * @param coordinateOptions options keyed by dependency coordinate (see {@link + * JavaagentExtension}) + * @return provider of a map from resolved artifact file name to its option string + */ + public static Provider> optionsByFileName( + final Configuration configuration, final Provider> coordinateOptions) { + return configuration + .getIncoming() + .getArtifacts() + .getResolvedArtifacts() + .map( + artifacts -> { + final Map coordinates = coordinateOptions.getOrElse(new HashMap<>()); + final Map byFileName = new HashMap<>(); + if (coordinates.isEmpty()) { + return byFileName; + } + for (final ResolvedArtifactResult artifact : artifacts) { + final String key = coordinateKey(artifact.getId().getComponentIdentifier()); + final String options = coordinates.get(key); + if (options != null) { + byFileName.put(artifact.getFile().getName(), options); + } + } + return byFileName; + }); + } + + /** + * Derives the coordinate key used to look up options for a resolved artifact. Module dependencies + * are keyed by {@code group:name} (version intentionally excluded so options survive version + * bumps), project dependencies by their project path, and anything else by its display name as a + * best-effort fallback. + */ + private static String coordinateKey(final ComponentIdentifier identifier) { + if (identifier instanceof ModuleComponentIdentifier) { + final ModuleComponentIdentifier module = (ModuleComponentIdentifier) identifier; + return module.getGroup() + ":" + module.getModule(); + } + if (identifier instanceof ProjectComponentIdentifier) { + return ((ProjectComponentIdentifier) identifier).getProjectPath(); + } + return identifier.getDisplayName(); + } +} diff --git a/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java b/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java index 5912925d..062b5933 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java +++ b/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java @@ -5,6 +5,7 @@ import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import org.gradle.api.provider.Provider; @@ -28,9 +29,13 @@ private JavaForkOptionsConfigurer() {} * * @param javaForkOptions to be configured * @param javaagentConfiguration files to be added as javaagents + * @param optionsByFileName options to append after {@code =}, keyed by agent file name (see + * {@link AgentOptionsResolver}) */ public static void configureJavaForkOptions( - JavaForkOptions javaForkOptions, Provider> javaagentConfiguration) { + JavaForkOptions javaForkOptions, + Provider> javaagentConfiguration, + Provider> optionsByFileName) { final List list = new ArrayList<>(); //noinspection Convert2Lambda @@ -38,11 +43,14 @@ public static void configureJavaForkOptions( new CommandLineArgumentProvider() { @Override public Iterable asArguments() { + final Map options = optionsByFileName.get(); return javaagentConfiguration.get().stream() .map( file -> { try { - return "-javaagent:" + file.getCanonicalPath(); + final String options0 = options.get(file.getName()); + final String suffix = options0 == null ? "" : "=" + options0; + return "-javaagent:" + file.getCanonicalPath() + suffix; } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt index f7174a37..82256440 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -30,6 +30,10 @@ class JavaagentApplicationDistributionPlugin : project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { + val extension = project.extensions.getByType(JavaagentExtension::class.java) + val optionsByFileName = + AgentOptionsResolver.optionsByFileName(javaagentConfiguration.get(), extension.agentOptions) + project.extensions .getByType(DistributionContainer::class.java) .named(DistributionPlugin.MAIN_DISTRIBUTION_NAME) @@ -52,10 +56,11 @@ class JavaagentApplicationDistributionPlugin : listOf("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar") .plus(it.defaultJvmOpts ?: listOf()) it.inputs.files(javaagentConfiguration) + it.inputs.property("agentOptions", extension.agentOptions) // custom start script generator that replaces the placeholder val agentFiles: Provider> = javaagentConfiguration.map { configuration -> configuration.files } - it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX) - it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS) + it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX, optionsByFileName) + it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS, optionsByFileName) } } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt index d446e04c..757bab87 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt @@ -19,6 +19,9 @@ class JavaagentApplicationRunPlugin : project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { + val extension = project.extensions.getByType(JavaagentExtension::class.java) + val optionsByFileName = + AgentOptionsResolver.optionsByFileName(javaagentConfiguration.get(), extension.agentOptions) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(ApplicationPlugin.TASK_RUN_NAME, JavaExec::class.java).configure { // The agent jars are passed to the JVM via a CommandLineArgumentProvider (see @@ -34,7 +37,12 @@ class JavaagentApplicationRunPlugin : // carry its producing-task dependency (builtBy); consumers that synthesize a plain File must // preserve it (see the otel example's extendedAgent wiring). it.inputs.files(javaagentConfiguration) - JavaForkOptionsConfigurer.configureJavaForkOptions(it, javaagentConfiguration.map { configuration -> configuration.files }) + it.inputs.property("agentOptions", extension.agentOptions) + JavaForkOptionsConfigurer.configureJavaForkOptions( + it, + javaagentConfiguration.map { configuration -> configuration.files }, + optionsByFileName, + ) } } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt index 8a51619a..ffe084e6 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt @@ -12,6 +12,7 @@ import java.io.Writer class JavaagentAwareStartScriptGenerator( private val javaagentConfiguration: Provider>, private val platform: Platform, + private val optionsByFileName: Provider>, private val inner: ScriptGenerator = DefaultTemplateBasedStartScriptGenerator( platform.lineSeparator, @@ -25,7 +26,14 @@ class JavaagentAwareStartScriptGenerator( ) { inner.generateScript( details, - Fake(destination, javaagentConfiguration, platform.pathSeparator, platform.appHomeVar, platform.agentArgSeparator), + Fake( + destination, + javaagentConfiguration, + platform.pathSeparator, + platform.appHomeVar, + platform.agentArgSeparator, + optionsByFileName, + ), ) } @@ -53,6 +61,7 @@ class JavaagentAwareStartScriptGenerator( private val pathSeparator: String, private val appHomeVar: String, private val agentArgSeparator: String, + private val optionsByFileName: Provider>, ) : Writer() { override fun close() { inner.close() @@ -97,11 +106,15 @@ class JavaagentAwareStartScriptGenerator( .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar ", "") .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", "") } else { + val options = optionsByFileName.get() str.replace( "-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", files.joinToString( agentArgSeparator, - ) { jar -> "-javaagent:$appHomeVar${pathSeparator}agent-libs$pathSeparator${jar.name}" }, + ) { jar -> + val suffix = options[jar.name]?.let { "=$it" } ?: "" + "-javaagent:$appHomeVar${pathSeparator}agent-libs$pathSeparator${jar.name}$suffix" + }, ) } super.write(replace) diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt index b60b1f63..6f964a00 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt @@ -9,6 +9,7 @@ import org.gradle.api.Project class JavaagentBasePlugin : Plugin { companion object { const val CONFIGURATION_NAME = "javaagent" + const val EXTENSION_NAME = "javaagent" } override fun apply(project: Project) { @@ -17,5 +18,10 @@ class JavaagentBasePlugin : Plugin { // we expect javaagents to come as shaded JARs it.isTransitive = false } + // Register the extension used to configure agent options. Created once here because the base plugin is + // applied idempotently by JavaagentPlugin, so the `javaagent { }` block is available to every plugin + // (application, distribution, test, jib). The extension name does not collide with the `javaagent` + // configuration, which is reached via the `configurations` container. + project.extensions.create(EXTENSION_NAME, JavaagentExtension::class.java) } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt new file mode 100644 index 00000000..bdf4ac93 --- /dev/null +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt @@ -0,0 +1,34 @@ +package com.ryandens.javaagent + +import org.gradle.api.provider.MapProperty + +/** + * Extension for configuring the javaagents attached by this plugin. + * + * Currently exposes [agentOptions], which associates agent option strings (the `=` portion of the + * `-javaagent:=` JVM argument, see the + * [java.lang.instrument package documentation](https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/package-summary.html#starting-an-agent-from-the-command-line-interface-heading)) + * with the agent they apply to. + * + * Options are keyed by the dependency coordinate the user declared in the `javaagent` (or `testJavaagent`) + * configuration, so they survive version bumps and do not depend on the resolved jar file name: + * - module dependencies are keyed by `group:name` (version is intentionally excluded) + * - project dependencies are keyed by their project path, e.g. `:simple-agent` + * + * ``` + * dependencies { + * javaagent 'io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0' + * } + * + * javaagent { + * agentOptions.put('io.prometheus.jmx:jmx_prometheus_javaagent', '12345:config.yaml') + * } + * ``` + */ +abstract class JavaagentExtension { + /** + * Maps an agent's dependency coordinate (`group:name` for module dependencies, project path for project + * dependencies) to the option string appended after `=` in the `-javaagent:=` argument. + */ + abstract val agentOptions: MapProperty +} diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt index 955859b2..80fa3d77 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt @@ -46,14 +46,20 @@ class JavaagentTestPlugin : val enabled = calculateEnabled(project, extension) + val javaagentExtension = project.extensions.getByType(JavaagentExtension::class.java) + val optionsByFileName = + AgentOptionsResolver.optionsByFileName(javaagentTestConfiguration.get(), javaagentExtension.agentOptions) + // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(JavaPlugin.TEST_TASK_NAME, Test::class.java).configure { if (enabled.get()) { + it.inputs.property("agentOptions", javaagentExtension.agentOptions) JavaForkOptionsConfigurer.configureJavaForkOptions( it, javaagentTestConfiguration.map { configuration -> configuration.files }, + optionsByFileName, ) } } diff --git a/simple-agent/src/main/java/com/ryandens/SimpleAgent.java b/simple-agent/src/main/java/com/ryandens/SimpleAgent.java index 064566c0..b25df492 100644 --- a/simple-agent/src/main/java/com/ryandens/SimpleAgent.java +++ b/simple-agent/src/main/java/com/ryandens/SimpleAgent.java @@ -6,5 +6,8 @@ public final class SimpleAgent { public static void premain(final String args, final Instrumentation instrumentation) { System.out.println("Hello from my simple agent!"); + if (args != null) { + System.out.println("Simple agent args: " + args); + } } } From 85456d5f87f92d29f4fbd786566cc926d683ebd1 Mon Sep 17 00:00:00 2001 From: "macroscopeapp[bot]" <170038800+macroscopeapp[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:20:07 -0700 Subject: [PATCH 2/7] (via ryandens): Use getOrElse(emptyMap()) for agentOptions to avoid IllegalStateExceptio (#391) ## Summary Fixes an `IllegalStateException` that occurs when `agentOptions` is not explicitly configured in the Jib extension. ## Changes - Updated `JavaagentJibExtension.kt` to use `getOrElse(emptyMap())` instead of `get()` when accessing `agentOptions` - When `agentOptions` is not configured, the code now gracefully defaults to an empty map rather than throwing an exception ## Problem Previously, calling `extraConfig.get().agentOptions.get()` would throw an `IllegalStateException` if the user had not set any agent options in their configuration. This made `agentOptions` effectively required even though it should be optional. ## Solution Using `getOrElse(emptyMap())` provides a safe default value, making `agentOptions` truly optional as intended. > [!NOTE] > ### Macroscope: _Fix It For Me_ > - This PR originated from [this comment](https://github.com/ryandens/javaagent-gradle-plugin/pull/379/files#r3641209014) in #379. > - Since auto-merge is on, Macroscope will merge this PR after waiting for checks to pass. > - If you'd rather not wait, you can always merge this yourself but **no further action from you is currently needed**. > - You can also @mention Macroscope in this PR to request further changes. > > #### Activity > Currently: _Waiting on checks_ > >

> Previously > > > - Pushed ce934b73ffe89539df8aad53e79910e8193bd70b > - Action failed: Validate > - Waiting on checks > - Pushed c882d5a569a8937daab96b7219a6297165f82f10 > - Working on next commit... > - Waiting on checks > - Pushed 089c78f8714468db4f56f0211fd26fc04dec4e3b > > >
---- > [!NOTE] > ### Fix `IllegalStateException` by keying agent options on canonical file path > - Fixes a runtime `IllegalStateException` in `JavaagentJibExtension` by replacing `Provider.get()` with `Provider.getOrElse(emptyMap())` when no `agentOptions` are configured. > - Changes `AgentOptionsResolver.optionsByFileName` to `optionsByFilePath`, keying the options map by each artifact's canonical file path instead of its file name. All consumers (`JavaForkOptionsConfigurer`, `JavaagentAwareStartScriptGenerator`, Jib plugin, run/test/distribution plugins) are updated to match. > - Behavioral Change: agent option lookups in JVM fork args and generated start scripts now use canonical paths; configs that relied on file-name keying will need to be re-evaluated if duplicate file names exist across different directories. > > > > Macroscope summarized ce934b7. > > --------- Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- .../javaagent/jib/JavaagentJibExtension.kt | 2 +- .../javaagent/jib/JavaagentJibPlugin.kt | 9 ++++++- .../javaagent/AgentOptionsResolver.java | 26 ++++++++++++------- .../javaagent/JavaForkOptionsConfigurer.java | 8 +++--- .../JavaagentApplicationDistributionPlugin.kt | 8 +++--- .../JavaagentApplicationRunPlugin.kt | 6 ++--- .../JavaagentAwareStartScriptGenerator.kt | 10 +++---- .../ryandens/javaagent/JavaagentTestPlugin.kt | 6 ++--- 8 files changed, 44 insertions(+), 31 deletions(-) diff --git a/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt b/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt index 0f531a19..964a3dad 100644 --- a/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt +++ b/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt @@ -46,7 +46,7 @@ class JavaagentJibExtension : } val localAgentPaths = extraConfig.get().javaagentFiles.get() - val agentOptions = extraConfig.get().agentOptions.get() + val agentOptions = extraConfig.get().agentOptions.getOrElse(emptyMap()) val planBuilder = buildPlan.toBuilder() val newEntrypoint = diff --git a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt index 51e448e2..13e0d34a 100644 --- a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt +++ b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt @@ -24,8 +24,15 @@ class JavaagentJibPlugin : Plugin { val javaagentConfiguration = project.configurations.named(JavaagentBasePlugin.CONFIGURATION_NAME) val javaagentExtension = project.extensions.getByType(JavaagentExtension::class.java) + val optionsByFilePath = + AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get(), javaagentExtension.agentOptions) + // Transform to file-name keyed map for the Jib extension, which looks up by the copied file's name val optionsByFileName = - AgentOptionsResolver.optionsByFileName(javaagentConfiguration.get(), javaagentExtension.agentOptions) + optionsByFilePath.map { pathMap -> + pathMap.entries.associate { (path, options) -> + java.io.File(path).name to options + } + } val destinationDirectory = project.tasks diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java index 5f1149eb..ba27b7f5 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -1,6 +1,8 @@ package com.ryandens.javaagent; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.HashMap; import java.util.Map; import org.gradle.api.artifacts.Configuration; @@ -12,9 +14,9 @@ /** * Resolves the {@link JavaagentExtension#getAgentOptions() agent options} declared by coordinate - * into a map keyed by the resolved artifact file name, which is the join key every {@code - * -javaagent:} argument site can compute (the run/test sites hold the {@link File}, the - * distribution and jib sites use {@code file.getName()}). + * into a map keyed by the resolved artifact's full canonical file path, which is the join key every + * {@code -javaagent:} argument site can compute (the run/test sites hold the {@link File}, the + * distribution and jib sites use {@code file.getCanonicalPath()}). * *

This class is written in Java for the same reason as {@link JavaForkOptionsConfigurer}: to * avoid the options} map from the resolved artifacts of the provided + * Builds a {@code filePath -> options} map from the resolved artifacts of the provided * configuration and the coordinate-keyed options declared on the {@link JavaagentExtension}. Only * agents that have options declared appear in the resulting map. * @@ -38,9 +40,9 @@ private AgentOptionsResolver() {} * @param configuration the resolvable javaagent configuration whose artifacts should be matched * @param coordinateOptions options keyed by dependency coordinate (see {@link * JavaagentExtension}) - * @return provider of a map from resolved artifact file name to its option string + * @return provider of a map from resolved artifact file path to its option string */ - public static Provider> optionsByFileName( + public static Provider> optionsByFilePath( final Configuration configuration, final Provider> coordinateOptions) { return configuration .getIncoming() @@ -49,18 +51,22 @@ public static Provider> optionsByFileName( .map( artifacts -> { final Map coordinates = coordinateOptions.getOrElse(new HashMap<>()); - final Map byFileName = new HashMap<>(); + final Map byFilePath = new HashMap<>(); if (coordinates.isEmpty()) { - return byFileName; + return byFilePath; } for (final ResolvedArtifactResult artifact : artifacts) { final String key = coordinateKey(artifact.getId().getComponentIdentifier()); final String options = coordinates.get(key); if (options != null) { - byFileName.put(artifact.getFile().getName(), options); + try { + byFilePath.put(artifact.getFile().getCanonicalPath(), options); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } } - return byFileName; + return byFilePath; }); } diff --git a/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java b/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java index 062b5933..da42f841 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java +++ b/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java @@ -29,13 +29,13 @@ private JavaForkOptionsConfigurer() {} * * @param javaForkOptions to be configured * @param javaagentConfiguration files to be added as javaagents - * @param optionsByFileName options to append after {@code =}, keyed by agent file name (see + * @param optionsByFilePath options to append after {@code =}, keyed by agent file path (see * {@link AgentOptionsResolver}) */ public static void configureJavaForkOptions( JavaForkOptions javaForkOptions, Provider> javaagentConfiguration, - Provider> optionsByFileName) { + Provider> optionsByFilePath) { final List list = new ArrayList<>(); //noinspection Convert2Lambda @@ -43,12 +43,12 @@ public static void configureJavaForkOptions( new CommandLineArgumentProvider() { @Override public Iterable asArguments() { - final Map options = optionsByFileName.get(); + final Map options = optionsByFilePath.get(); return javaagentConfiguration.get().stream() .map( file -> { try { - final String options0 = options.get(file.getName()); + final String options0 = options.get(file.getCanonicalPath()); final String suffix = options0 == null ? "" : "=" + options0; return "-javaagent:" + file.getCanonicalPath() + suffix; } catch (IOException e) { diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt index 82256440..8a068793 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -31,8 +31,8 @@ class JavaagentApplicationDistributionPlugin : javaagentConfiguration: NamedDomainObjectProvider, ) { val extension = project.extensions.getByType(JavaagentExtension::class.java) - val optionsByFileName = - AgentOptionsResolver.optionsByFileName(javaagentConfiguration.get(), extension.agentOptions) + val optionsByFilePath = + AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get(), extension.agentOptions) project.extensions .getByType(DistributionContainer::class.java) @@ -59,8 +59,8 @@ class JavaagentApplicationDistributionPlugin : it.inputs.property("agentOptions", extension.agentOptions) // custom start script generator that replaces the placeholder val agentFiles: Provider> = javaagentConfiguration.map { configuration -> configuration.files } - it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX, optionsByFileName) - it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS, optionsByFileName) + it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX, optionsByFilePath) + it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS, optionsByFilePath) } } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt index 757bab87..300d4de7 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt @@ -20,8 +20,8 @@ class JavaagentApplicationRunPlugin : javaagentConfiguration: NamedDomainObjectProvider, ) { val extension = project.extensions.getByType(JavaagentExtension::class.java) - val optionsByFileName = - AgentOptionsResolver.optionsByFileName(javaagentConfiguration.get(), extension.agentOptions) + val optionsByFilePath = + AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get(), extension.agentOptions) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(ApplicationPlugin.TASK_RUN_NAME, JavaExec::class.java).configure { // The agent jars are passed to the JVM via a CommandLineArgumentProvider (see @@ -41,7 +41,7 @@ class JavaagentApplicationRunPlugin : JavaForkOptionsConfigurer.configureJavaForkOptions( it, javaagentConfiguration.map { configuration -> configuration.files }, - optionsByFileName, + optionsByFilePath, ) } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt index ffe084e6..3c7819ee 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt @@ -12,7 +12,7 @@ import java.io.Writer class JavaagentAwareStartScriptGenerator( private val javaagentConfiguration: Provider>, private val platform: Platform, - private val optionsByFileName: Provider>, + private val optionsByFilePath: Provider>, private val inner: ScriptGenerator = DefaultTemplateBasedStartScriptGenerator( platform.lineSeparator, @@ -32,7 +32,7 @@ class JavaagentAwareStartScriptGenerator( platform.pathSeparator, platform.appHomeVar, platform.agentArgSeparator, - optionsByFileName, + optionsByFilePath, ), ) } @@ -61,7 +61,7 @@ class JavaagentAwareStartScriptGenerator( private val pathSeparator: String, private val appHomeVar: String, private val agentArgSeparator: String, - private val optionsByFileName: Provider>, + private val optionsByFilePath: Provider>, ) : Writer() { override fun close() { inner.close() @@ -106,13 +106,13 @@ class JavaagentAwareStartScriptGenerator( .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar ", "") .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", "") } else { - val options = optionsByFileName.get() + val options = optionsByFilePath.get() str.replace( "-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", files.joinToString( agentArgSeparator, ) { jar -> - val suffix = options[jar.name]?.let { "=$it" } ?: "" + val suffix = options[jar.canonicalPath]?.let { "=$it" } ?: "" "-javaagent:$appHomeVar${pathSeparator}agent-libs$pathSeparator${jar.name}$suffix" }, ) diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt index 80fa3d77..c86b92df 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt @@ -47,8 +47,8 @@ class JavaagentTestPlugin : val enabled = calculateEnabled(project, extension) val javaagentExtension = project.extensions.getByType(JavaagentExtension::class.java) - val optionsByFileName = - AgentOptionsResolver.optionsByFileName(javaagentTestConfiguration.get(), javaagentExtension.agentOptions) + val optionsByFilePath = + AgentOptionsResolver.optionsByFilePath(javaagentTestConfiguration.get(), javaagentExtension.agentOptions) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(JavaPlugin.TEST_TASK_NAME, Test::class.java).configure { @@ -59,7 +59,7 @@ class JavaagentTestPlugin : javaagentTestConfiguration.map { configuration -> configuration.files }, - optionsByFileName, + optionsByFilePath, ) } } From d85d08172f7f6997446c0c75a27962b11f87dd0a Mon Sep 17 00:00:00 2001 From: "macroscopeapp[bot]" <170038800+macroscopeapp[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:20:07 -0700 Subject: [PATCH 3/7] (via ryandens): Fix shell injection vulnerability in JavaagentAwareStartScriptGenerator (#392) ## Summary Fixes a shell injection vulnerability in `JavaagentAwareStartScriptGenerator.Fake.write()` by adding platform-aware shell escaping for javaagent option strings. ## Changes Added proper escaping for agent option values to prevent malicious input from being interpreted as shell commands: - **Unix scripts**: Option values are wrapped in single quotes with internal single quotes escaped as `'\''` - **Windows scripts**: `%` characters are doubled to `%%` to prevent environment variable expansion ## Security Impact Previously, unescaped option strings passed to the javaagent could potentially be exploited to execute arbitrary shell commands. This fix ensures that special characters in option values are properly escaped for the target platform's shell interpreter. > [!NOTE] > ### Macroscope: _Fix It For Me_ > - This PR originated from [this comment](https://github.com/ryandens/javaagent-gradle-plugin/pull/379/files#r3641346092) in #379. > - Since auto-merge is on, Macroscope will merge this PR after waiting for checks to pass. > - If you'd rather not wait, you can always merge this yourself but **no further action from you is currently needed**. > - You can also @mention Macroscope in this PR to request further changes. > > #### Activity > Currently: _Waiting on checks_ > >

> Previously > > > - Pushed 51d4603abe686d3c6d723d60caf1a3e00637c642 > - Action failed: Validate > - Waiting on checks > - Pushed a62e38133e31c49e0d840029a75382a1be2f0a9a > > >
---- > [!NOTE] > ### Fix shell injection vulnerability in `JavaagentAwareStartScriptGenerator` by escaping option values > - On Unix, javaagent option values are wrapped in single quotes with internal single quotes escaped to prevent shell injection. > - On Windows, percent signs in option values are doubled to prevent variable expansion. > - The [`Writer`](https://github.com/ryandens/javaagent-gradle-plugin/pull/392/files#diff-b35a17536e95a01f0d06c07c9255cbcd47cfceeb6231638cda0393b65d70967b) now accepts a `platform` argument to apply the correct escaping strategy per OS. > - Behavioral Change: generated start scripts now quote javaagent option values, which changes the output format of `DEFAULT_JVM_OPTS` on Unix. > > > > Macroscope summarized 51d4603. > > --------- Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- .../javaagent/JavaagentPluginFunctionalTest.kt | 3 ++- .../JavaagentAwareStartScriptGenerator.kt | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index dde977b5..ad1b22c6 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -284,9 +284,10 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertTrue(applicationDistributionScript.readText().contains(expectedWindowsDefaultJvmOpts)) } else { // The option string rides inside the agent's own backslash-escaped quoted token -- see issue #95. + // Option values are wrapped in single quotes to prevent shell injection (issue #50). val expectedDefaultJavaOpts = """ -DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar=12345:config.yaml\" \"-Xmx256m\"" +DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar='12345:config.yaml'\" \"-Xmx256m\"" """ assertTrue(applicationDistributionScript.readText().contains(expectedDefaultJavaOpts)) } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt index 3c7819ee..900e842e 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt @@ -33,6 +33,7 @@ class JavaagentAwareStartScriptGenerator( platform.appHomeVar, platform.agentArgSeparator, optionsByFilePath, + platform, ), ) } @@ -62,6 +63,7 @@ class JavaagentAwareStartScriptGenerator( private val appHomeVar: String, private val agentArgSeparator: String, private val optionsByFilePath: Provider>, + private val platform: Platform, ) : Writer() { override fun close() { inner.close() @@ -79,6 +81,17 @@ class JavaagentAwareStartScriptGenerator( inner.write(cbuf, off, len) } + /** + * Escapes a javaagent option value for safe inclusion in shell scripts. + * - For Unix: wraps in single quotes, escaping internal single quotes with '\'' + * - For Windows: doubles percent signs to prevent variable expansion + */ + private fun escapeOptionValue(value: String): String = + when (platform) { + Platform.UNIX -> "'" + value.replace("'", "'\\''") + "'" + Platform.WINDOWS -> value.replace("%", "%%") + } + /** * Rewrites the javaagent placeholder that the templated `defaultJvmOpts` injects into the rendered start * script. When the configuration resolves to one or more agents, the single placeholder is replaced by one @@ -112,7 +125,7 @@ class JavaagentAwareStartScriptGenerator( files.joinToString( agentArgSeparator, ) { jar -> - val suffix = options[jar.canonicalPath]?.let { "=$it" } ?: "" + val suffix = options[jar.canonicalPath]?.let { "=" + escapeOptionValue(it) } ?: "" "-javaagent:$appHomeVar${pathSeparator}agent-libs$pathSeparator${jar.name}$suffix" }, ) From 9f71de2f166ce011a707ad3a84f2b9911a422662 Mon Sep 17 00:00:00 2001 From: Ryan Dens Date: Sat, 17 Oct 2026 00:20:07 -0700 Subject: [PATCH 4/7] Configure javaagent options per dependency and plugin context --- README.md | 53 +++- .../javaagent/jib/JavaagentJibExtension.kt | 6 +- .../JavaagentJibExtensionFunctionalTest.kt | 67 ++++- .../javaagent/jib/JavaagentJibPlugin.kt | 7 +- .../javaagent/jib/JavaagentJibPluginTest.kt | 6 + .../JavaagentPluginFunctionalTest.kt | 265 +++++++++++++++--- .../com/ryandens/javaagent/AgentOptions.java | 28 ++ .../javaagent/AgentOptionsResolver.java | 87 +++--- .../javaagent/DependencyAgentOptions.kt | 18 ++ .../JavaagentApplicationDistributionPlugin.kt | 17 +- .../JavaagentApplicationRunPlugin.kt | 15 +- .../JavaagentAwareStartScriptGenerator.kt | 16 +- .../ryandens/javaagent/JavaagentBasePlugin.kt | 22 +- .../ryandens/javaagent/JavaagentExtension.kt | 34 --- .../ryandens/javaagent/JavaagentTestPlugin.kt | 6 +- .../JavaagentApplicationPluginTest.kt | 23 ++ 16 files changed, 508 insertions(+), 162 deletions(-) create mode 100644 plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java create mode 100644 plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt delete mode 100644 plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt diff --git a/README.md b/README.md index 5033253c..ce60bf84 100644 --- a/README.md +++ b/README.md @@ -71,30 +71,59 @@ Some javaagents accept options via the `-javaagent:=` form of the For example, the [Prometheus JMX Exporter](https://github.com/prometheus/jmx_exporter) agent is configured as `-javaagent:jmx_prometheus_javaagent.jar=12345:config.yaml`. -Use the `javaagent` extension to associate options with an agent. Options are keyed by the dependency -coordinate you declared, so they are independent of the resolved jar file name and survive version bumps: +Agent options belong to dependency declarations. Each plugin provides a configuration for its own +execution or packaging context: -* module dependencies are keyed by `group:name` (the version is intentionally excluded) -* project dependencies are keyed by their project path, e.g. `:my-agent` +| Plugin | Dependency configuration | Used by | +| --- | --- | --- | +| `com.ryandens.javaagent-application-run` | `runJavaagent` | Local application `run` | +| `com.ryandens.javaagent-test` | `testJavaagent` | Java `test` | +| `com.ryandens.javaagent-application-distribution` | `distributionJavaagent` | `installDist`, `distTar`, and `distZip` launchers | +| `com.ryandens.javaagent-jib` | `jibJavaagent` | Jib container image entrypoints | + +The `com.ryandens.javaagent-application` convenience plugin applies both application plugins and +provides both `runJavaagent` and `distributionJavaagent`. Apply only the plugins needed for your +project; each registers its own configuration. + +In Kotlin DSL, import `agentOptions` and set it on each dependency: ```kotlin +import com.ryandens.javaagent.agentOptions + plugins { application id("com.ryandens.javaagent-application") version "0.11.0" + id("com.ryandens.javaagent-test") version "0.11.0" + id("com.google.cloud.tools.jib") version "3.5.3" + id("com.ryandens.javaagent-jib") version "0.11.0" } dependencies { - javaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") -} - -javaagent { - agentOptions.put("io.prometheus.jmx:jmx_prometheus_javaagent", "12345:config.yaml") + runJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { + agentOptions = "12345:config/local.yaml" + } + testJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { + agentOptions = "12346:config/test.yaml" + } + distributionJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { + agentOptions = "12345:config/distribution.yaml" + } + jibJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { + agentOptions = "12345:/etc/agent/container.yaml" + } } ``` -The same options are applied consistently across every context this plugin configures: the application `run` -task, the application distribution start scripts, the `test` task, and jib container entrypoints. Agents without -a matching entry are attached without an options suffix, exactly as before. +Agents declared only in a plugin's configuration are included only in that context. Project +dependencies work the same way, for example `runJavaagent(project(":my-agent")) { agentOptions = "..." }`. +Tar, zip, and installed distributions share options because they package the same generated launchers. + +The existing `javaagent` dependency configuration is shared: declarations there are inherited by each +plugin-specific configuration. Use it when the same agent and options should apply to every enabled +context. Options explicitly set on a plugin-specific declaration override inherited options for that +resolved agent. An empty string overrides inherited options with an empty options suffix. Conflicting +options on two direct declarations of the same resolved agent fail with an error. Agents without +configured options are attached without an options suffix. ## Jib integration diff --git a/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt b/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt index 964a3dad..337849da 100644 --- a/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt +++ b/jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt @@ -9,6 +9,7 @@ import com.google.cloud.tools.jib.api.buildplan.LayerObject import com.google.cloud.tools.jib.gradle.extension.GradleData import com.google.cloud.tools.jib.gradle.extension.JibGradlePluginExtension import com.google.cloud.tools.jib.plugins.extension.ExtensionLogger +import com.ryandens.javaagent.JavaagentBasePlugin import com.ryandens.javaagent.JavaagentPlugin import org.gradle.api.GradleException import org.gradle.api.NamedDomainObjectProvider @@ -28,6 +29,7 @@ class JavaagentJibExtension : JavaagentPlugin { companion object { const val COPY_AGENTS_TASK_NAME = "copyAgentsToJibDir" + const val CONFIGURATION_NAME = "jibJavaagent" } override fun getExtraConfigType(): Optional> = Optional.of(JibExtensionConfiguration::class.java) @@ -87,10 +89,12 @@ class JavaagentJibExtension : project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { + val jibJavaagentConfiguration = + JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val destinationDirectory = project.layout.buildDirectory.dir("jib-agents") val copyAgents = project.tasks.register(COPY_AGENTS_TASK_NAME, Copy::class.java) { - it.from(javaagentConfiguration) + it.from(jibJavaagentConfiguration) it.into(destinationDirectory) } diff --git a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt index dd29bfb5..e19bede1 100644 --- a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt +++ b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt @@ -61,7 +61,9 @@ class JavaagentJibExtensionFunctionalTest { @Test fun `can pass agent options to jib entrypoint`() { val dependencies = """ - javaagent project(':simple-agent') + jibJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } runtimeOnly 'commons-lang:commons-lang:2.6' """ @@ -69,12 +71,6 @@ class JavaagentJibExtensionFunctionalTest { createAndBuildJavaagentProject( dependencies, listOf("jibBuildTar"), - extraBuildScript = - """ - javaagent { - agentOptions.put(':simple-agent', '12345:config.yaml') - } - """, ) assertTrue(result.output.contains("Running extension: com.ryandens.javaagent.jib.JavaagentJibExtension")) @@ -94,6 +90,55 @@ class JavaagentJibExtensionFunctionalTest { } } + @Test fun `can pass dependency scoped options to jib entrypoint`() { + createAndBuildJavaagentProject( + "", + listOf("jibBuildTar"), + kotlinBuildScript = + """ + import com.ryandens.javaagent.agentOptions + + plugins { + application + id("com.google.cloud.tools.jib") + id("com.ryandens.javaagent-jib") + id("com.ryandens.javaagent-application") + } + application { mainClass.set("com.ryandens.HelloWorld") } + dependencies { + runJavaagent(project(":simple-agent")) { agentOptions = "local-only-options" } + distributionJavaagent(project(":simple-agent")) { agentOptions = "distribution-only-options" } + jibJavaagent(project(":simple-agent")) { + agentOptions = "12345:dependency-config.yaml" + } + } + jib { + from { image = "scratch" } + to { image = "javaagent-hello-world" } + container { mainClass = "com.ryandens.HelloWorld" } + } + """, + ) + + var foundConfiguration = false + FileInputStream(File(functionalTestDir, JIB_IMAGE)).use { fis -> + ArchiveStreamFactory().createArchiveInputStream>(ArchiveStreamFactory.TAR, fis).use { ais -> + var entry = ais.nextEntry + while (entry != null) { + if (entry.name == "config.json") { + foundConfiguration = true + val json = ais.readBytes().toString(Charsets.UTF_8) + assertTrue(json.contains("-javaagent:/opt/jib-agents/simple-agent.jar=12345:dependency-config.yaml")) + assertFalse(json.contains("local-only-options")) + assertFalse(json.contains("distribution-only-options")) + } + entry = ais.nextEntry + } + } + } + assertTrue(foundConfiguration, "The image must contain the configuration with the agent entrypoint") + } + @Test fun `works even without any javaagent dependencies`() { val dependencies = """ runtimeOnly 'commons-lang:commons-lang:2.6' @@ -124,7 +169,7 @@ class JavaagentJibExtensionFunctionalTest { private fun createAndBuildJavaagentProject( dependencies: String, buildArgs: List, - extraBuildScript: String = "", + kotlinBuildScript: String? = null, ): BuildResult { val helloWorldDir = File(functionalTestDir, "hello-world") File( @@ -149,8 +194,8 @@ class JavaagentJibExtensionFunctionalTest { """, ) - helloWorldDir.resolve("build.gradle").writeText( - """ + helloWorldDir.resolve(if (kotlinBuildScript == null) "build.gradle" else "build.gradle.kts").writeText( + kotlinBuildScript ?: """ plugins { id('application') id('com.google.cloud.tools.jib') version '3.1.4' @@ -186,8 +231,6 @@ class JavaagentJibExtensionFunctionalTest { image = "javaagent-hello-world" } } - - $extraBuildScript """, ) diff --git a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt index 13e0d34a..a4dd4ccb 100644 --- a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt +++ b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt @@ -2,8 +2,6 @@ package com.ryandens.javaagent.jib import com.google.cloud.tools.jib.gradle.JibExtension import com.ryandens.javaagent.AgentOptionsResolver -import com.ryandens.javaagent.JavaagentBasePlugin -import com.ryandens.javaagent.JavaagentExtension import org.gradle.api.Action import org.gradle.api.Plugin import org.gradle.api.Project @@ -21,11 +19,10 @@ class JavaagentJibPlugin : Plugin { override fun apply(project: Project) { project.pluginManager.apply(JavaagentJibExtension::class.java) - val javaagentConfiguration = project.configurations.named(JavaagentBasePlugin.CONFIGURATION_NAME) + val javaagentConfiguration = project.configurations.named(JavaagentJibExtension.CONFIGURATION_NAME) - val javaagentExtension = project.extensions.getByType(JavaagentExtension::class.java) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get(), javaagentExtension.agentOptions) + AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get()) // Transform to file-name keyed map for the Jib extension, which looks up by the copied file's name val optionsByFileName = optionsByFilePath.map { pathMap -> diff --git a/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt b/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt index 3b103daf..ae832097 100644 --- a/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt +++ b/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt @@ -7,6 +7,7 @@ import com.google.cloud.tools.jib.gradle.JibExtension import org.gradle.testfixtures.ProjectBuilder import kotlin.test.Test import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -22,6 +23,11 @@ class JavaagentJibPluginTest { // Verify the result assertNotNull(project.configurations.findByName("javaagent")) + assertNotNull(project.configurations.findByName("jibJavaagent")) + assertNull(project.configurations.findByName("runJavaagent")) + assertNull(project.configurations.findByName("distributionJavaagent")) + assertNull(project.configurations.findByName("testJavaagent")) + assertNull(project.extensions.findByName("javaagent")) val ext = project.extensions.getByType(JibExtension::class.java) assertTrue(ext.pluginExtensions.get().any { it.implementation == "com.ryandens.javaagent.jib.JavaagentJibExtension" }) diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index ad1b22c6..079d3e42 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -1,5 +1,6 @@ package com.ryandens.javaagent +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.gradle.internal.jvm.Jvm import org.gradle.internal.os.OperatingSystem import org.gradle.testkit.runner.BuildResult @@ -7,6 +8,7 @@ import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import java.io.File import java.nio.file.Paths +import java.util.zip.ZipFile import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -213,18 +215,12 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `can pass agent options to application run task`() { val dependencies = """ - javaagent project(':simple-agent') + runJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } """ - createJavaagentProject( - dependencies, - extraBuildScript = - """ - javaagent { - agentOptions.put(':simple-agent', '12345:config.yaml') - } - """, - ) + createJavaagentProject(dependencies) val result = runBuild(listOf("assemble", "run")) assertTrue(result.output.contains("Hello World!")) @@ -233,23 +229,199 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) } - @Test fun `passes agent options only to the agent they are keyed to`() { - // Two agents declared, but options only keyed to the project agent. The other agent must not receive them. + @Test fun `can configure independent options in each plugin configuration`() { + createJavaagentProject("") + // Publish the fixture agent locally so the consumer uses the external module overload, + // exactly as in the documented Kotlin DSL example. + File(functionalTestDir, "simple-agent/build.gradle.kts").appendText( + """ + + apply(plugin = "maven-publish") + group = "com.ryandens.fixture" + version = "1.0" + configure { + publications { + create("agent") { from(components["java"]) } + } + repositories { maven { url = uri(rootProject.file("repo")) } } + } + """, + ) + runBuild(listOf(":simple-agent:publish")) + helloWorldDir.resolve("build.gradle").delete() + helloWorldDir.resolve("build.gradle.kts").writeText( + """ + import com.ryandens.javaagent.agentOptions + + plugins { + application + id("com.ryandens.javaagent-application") + id("com.ryandens.javaagent-test") + } + repositories { + maven { url = uri(rootProject.file("repo")) } + mavenCentral() + } + application { mainClass.set("com.ryandens.HelloWorld") } + dependencies { + javaagent("com.ryandens.fixture:simple-agent:1.0") { + agentOptions = "shared-options" + } + runJavaagent("com.ryandens.fixture:simple-agent:1.0") { + agentOptions = "12345:config.yaml" + } + distributionJavaagent("com.ryandens.fixture:simple-agent:1.0") { + agentOptions = "distribution-options" + } + testJavaagent("com.ryandens.fixture:simple-agent:1.0") { + agentOptions = "test-options" + } + testImplementation(platform("org.junit:junit-bom:5.11.2")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + } + tasks.test { + useJUnitPlatform() + testLogging.showStandardStreams = true + } + """, + ) + val arguments = + listOf("--configuration-cache", ":hello-world:run", ":hello-world:test", ":hello-world:distTar", ":hello-world:distZip") + val result = runBuild(arguments) + assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) + assertTrue(result.output.contains("Simple agent args: test-options")) + assertFalse(result.output.contains("Simple agent args: shared-options")) + val script = helloWorldDir.resolve("build/scripts/hello-world$scriptExtension").readText() + assertTrue(script.contains("distribution-options")) + assertFalse(script.contains("12345:config.yaml")) + assertFalse(script.contains("test-options")) + assertFalse(script.contains("shared-options")) + ZipFile(helloWorldDir.resolve("build/distributions/hello-world.zip")).use { zip -> + val launcher = zip.getInputStream(zip.getEntry("hello-world/bin/hello-world$scriptExtension")).bufferedReader().readText() + assertEquals(script, launcher) + } + var tarLauncher: String? = null + TarArchiveInputStream( + helloWorldDir.resolve("build/distributions/hello-world.tar").inputStream(), + ).use { tar -> + var entry = tar.nextEntry + while (entry != null) { + if (entry.name == "hello-world/bin/hello-world$scriptExtension") { + tarLauncher = tar.readBytes().toString(Charsets.UTF_8) + } + entry = tar.nextEntry + } + } + assertEquals(script, tarLauncher) + val cachedResult = runBuild(arguments) + assertTrue(cachedResult.output.contains("Reusing configuration cache.")) + assertTrue(cachedResult.output.contains("Simple agent args: 12345:config.yaml")) + assertEquals(TaskOutcome.UP_TO_DATE, cachedResult.task(":hello-world:test")?.outcome) + val buildScript = helloWorldDir.resolve("build.gradle.kts") + buildScript.writeText(buildScript.readText().replace("test-options", "changed-test-options")) + val changedResult = runBuild(arguments) + assertEquals(TaskOutcome.SUCCESS, changedResult.task(":hello-world:test")?.outcome) + assertTrue(changedResult.output.contains("Simple agent args: changed-test-options")) + assertTrue(changedResult.output.contains("Simple agent args: 12345:config.yaml")) + assertEquals(TaskOutcome.UP_TO_DATE, changedResult.task(":hello-world:startScripts")?.outcome) + + assertEquals(TaskOutcome.UP_TO_DATE, changedResult.task(":hello-world:distTar")?.outcome) + assertEquals(TaskOutcome.UP_TO_DATE, changedResult.task(":hello-world:distZip")?.outcome) + buildScript.writeText(buildScript.readText().replace("distribution-options", "changed-distribution-options")) + val changedDistribution = runBuild(arguments) + assertEquals(TaskOutcome.SUCCESS, changedDistribution.task(":hello-world:startScripts")?.outcome) + assertEquals(TaskOutcome.SUCCESS, changedDistribution.task(":hello-world:distTar")?.outcome) + assertEquals(TaskOutcome.SUCCESS, changedDistribution.task(":hello-world:distZip")?.outcome) + assertEquals(TaskOutcome.UP_TO_DATE, changedDistribution.task(":hello-world:test")?.outcome) + assertTrue(changedDistribution.output.contains("Simple agent args: 12345:config.yaml")) + + // Options follow the selected artifact even when a module is substituted with a project. + buildScript.appendText( + """ + + configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("com.ryandens.fixture:simple-agent")).using(project(":simple-agent")) + } + } + """, + ) + val substitutedResult = runBuild(arguments) + assertTrue(substitutedResult.output.contains("Simple agent args: changed-test-options")) + assertTrue(substitutedResult.output.contains("Simple agent args: 12345:config.yaml")) + } + + @Test fun `run only agents are absent from tests and packaged distributions`() { + createJavaagentProject( + """ + runJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'local-only') } + } + """, + ) + val result = runBuild(listOf("--configuration-cache", "run", "test", "distZip", "distTar")) + assertEquals(1, result.output.lineSequence().count { it == "Hello from my simple agent!" }) + assertTrue(result.output.contains("Simple agent args: local-only")) + assertFalse(helloWorldDir.resolve("build/scripts/hello-world$scriptExtension").readText().contains("-javaagent:")) + ZipFile(helloWorldDir.resolve("build/distributions/hello-world.zip")).use { zip -> + assertFalse(zip.entries().asSequence().any { it.name.contains("agent-libs/") }) + } + } + + @Test fun `can configure options on a project agent dependency`() { + createJavaagentProject("") + helloWorldDir.resolve("build.gradle").delete() + helloWorldDir.resolve("build.gradle.kts").writeText( + """ + import com.ryandens.javaagent.agentOptions + + plugins { + application + id("com.ryandens.javaagent-application") + } + application { mainClass.set("com.ryandens.HelloWorld") } + dependencies { + javaagent(project(":simple-agent")) { + agentOptions = "project-options" + } + } + """, + ) + val result = runBuild(listOf("--configuration-cache", ":hello-world:run")) + assertTrue(result.output.contains("Simple agent args: project-options")) + val cachedResult = runBuild(listOf("--configuration-cache", ":hello-world:run")) + assertTrue(cachedResult.output.contains("Reusing configuration cache.")) + assertTrue(cachedResult.output.contains("Simple agent args: project-options")) + + val buildScript = helloWorldDir.resolve("build.gradle.kts") + buildScript.writeText(buildScript.readText().replace("agentOptions = \"project-options\"", "agentOptions = \"\"")) + val emptyResult = runBuild(listOf(":hello-world:run")) + assertTrue(emptyResult.output.lineSequence().any { it.trimEnd() == "Simple agent args:" }) + + buildScript.appendText( + """ + + dependencies { + javaagent(project(":simple-agent")) { agentOptions = "conflicting-options" } + } + """, + ) + val failedResult = runBuild(listOf(":hello-world:run"), expectFailure = true) + assertTrue(failedResult.output.contains("Conflicting javaagent options for")) + } + + @Test fun `passes agent options only to their dependency`() { + // Only the project agent has options; the other agent must not receive them. val otelVersion = "2.29.0" val dependencies = """ - javaagent project(':simple-agent') + javaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:$otelVersion' """ - createJavaagentProject( - dependencies, - extraBuildScript = - """ - javaagent { - agentOptions.put(':simple-agent', '12345:config.yaml') - } - """, - ) + createJavaagentProject(dependencies) val result = runBuild(listOf("assemble", "run")) assertTrue(result.output.contains("Hello World!")) @@ -262,18 +434,12 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `can pass agent options to application distribution start script`() { val dependencies = """ - javaagent project(':simple-agent') + distributionJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } """ - createJavaagentProject( - dependencies, - extraBuildScript = - """ - javaagent { - agentOptions.put(':simple-agent', '12345:config.yaml') - } - """, - ) + createJavaagentProject(dependencies) val result = runBuild(listOf("--configuration-cache", "build", "installDist", "execStartScript")) val applicationDistributionScript = @@ -284,10 +450,9 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertTrue(applicationDistributionScript.readText().contains(expectedWindowsDefaultJvmOpts)) } else { // The option string rides inside the agent's own backslash-escaped quoted token -- see issue #95. - // Option values are wrapped in single quotes to prevent shell injection (issue #50). val expectedDefaultJavaOpts = """ -DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar='12345:config.yaml'\" \"-Xmx256m\"" +DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar=12345:config.yaml\" \"-Xmx256m\"" """ assertTrue(applicationDistributionScript.readText().contains(expectedDefaultJavaOpts)) } @@ -296,6 +461,28 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar='12345 assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) } + @Test fun `distribution options preserve shell special characters`() { + val options = + if (isWindows) { + "hello world %PATH% ^ & |" + } else { + """hello world ${"$"}HOME ${"$"}(touch agent-options-expanded) `touch agent-options-backticks` "double" 'single' \ tail""" + } + val groovyOptions = options.replace("\\", "\\\\").replace("'", "\\'") + createJavaagentProject( + """ + distributionJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '$groovyOptions') } + } + """, + ) + val result = runBuild(listOf("installDist", "execStartScript")) + assertTrue(result.output.contains("Simple agent args: $options"), result.output) + val bin = helloWorldDir.resolve("build/install/hello-world/bin") + assertFalse(bin.resolve("agent-options-expanded").exists()) + assertFalse(bin.resolve("agent-options-backticks").exists()) + } + @Test fun `can handle upgrade of agent with build cache`() { createJavaagentProject( """ @@ -351,7 +538,6 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar='12345 private fun createJavaagentProject( dependencies: String, applicationDefaultJvmArgs: String = "['-Xmx256m']", - extraBuildScript: String = "", ) { val helloWorldDir = File(functionalTestDir, "hello-world") Paths.get("src", "functionalTest", "resources", "hello-world-project").toFile().copyRecursively(helloWorldDir) @@ -430,13 +616,14 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar='12345 testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } - - $extraBuildScript """, ) } - private fun runBuild(buildArgs: List): BuildResult { + private fun runBuild( + buildArgs: List, + expectFailure: Boolean = false, + ): BuildResult { // Run the build val runner = GradleRunner.create() runner.forwardOutput() @@ -457,6 +644,6 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar='12345 .toFile() runner.withTestKitDir(File(driveRoot, "gtk")) } - return runner.build() + return if (expectFailure) runner.buildAndFail() else runner.build() } } diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java new file mode 100644 index 00000000..ba44b08a --- /dev/null +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java @@ -0,0 +1,28 @@ +package com.ryandens.javaagent; + +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ModuleDependency; +import org.gradle.api.attributes.Attribute; + +/** Dependency attributes used to retain agent options through copying and resolution. */ +public final class AgentOptions { + /** The option string owned by an individual dependency declaration. */ + public static final Attribute OPTIONS = + Attribute.of("com.ryandens.javaagent.options", String.class); + + static final Attribute DECLARED_ON = + Attribute.of("com.ryandens.javaagent.declared-on", String.class); + + private AgentOptions() {} + + /** Records declaration scope so direct options take precedence over inherited options. */ + public static void trackDeclarations(final Configuration configuration) { + final String name = configuration.getName(); + configuration + .getDependencies() + .withType(ModuleDependency.class) + .all( + dependency -> + dependency.attributes(attributes -> attributes.attribute(DECLARED_ON, name))); + } +} diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java index ba27b7f5..58749e23 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -5,18 +5,20 @@ import java.io.UncheckedIOException; import java.util.HashMap; import java.util.Map; +import org.gradle.api.GradleException; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.component.ComponentIdentifier; -import org.gradle.api.artifacts.component.ModuleComponentIdentifier; -import org.gradle.api.artifacts.component.ProjectComponentIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.provider.Provider; /** - * Resolves the {@link JavaagentExtension#getAgentOptions() agent options} declared by coordinate - * into a map keyed by the resolved artifact's full canonical file path, which is the join key every - * {@code -javaagent:} argument site can compute (the run/test sites hold the {@link File}, the - * distribution and jib sites use {@code file.getCanonicalPath()}). + * Resolves options owned by dependencies in a plugin-specific configuration. Returns a map keyed by + * the resolved artifact's full canonical file path, which is the join key every {@code -javaagent:} + * argument site can compute (the run/test sites hold the {@link File}, the distribution and jib + * sites use {@code file.getCanonicalPath()}). * *

This class is written in Java for the same reason as {@link JavaForkOptionsConfigurer}: to * avoid the options} map from the resolved artifacts of the provided - * configuration and the coordinate-keyed options declared on the {@link JavaagentExtension}. Only - * agents that have options declared appear in the resulting map. + * configuration. Direct declarations override inherited declarations. Only agents that have + * options declared appear in the resulting map. * *

The returned {@link Provider} is lazy and configuration-cache safe: it reads the * configuration's resolved artifacts and the options map without touching {@code Project} at * execution time. * * @param configuration the resolvable javaagent configuration whose artifacts should be matched - * @param coordinateOptions options keyed by dependency coordinate (see {@link - * JavaagentExtension}) * @return provider of a map from resolved artifact file path to its option string */ - public static Provider> optionsByFilePath( - final Configuration configuration, final Provider> coordinateOptions) { + public static Provider> optionsByFilePath(final Configuration configuration) { + final String configurationName = configuration.getName(); return configuration .getIncoming() - .getArtifacts() - .getResolvedArtifacts() - .map( - artifacts -> { - final Map coordinates = coordinateOptions.getOrElse(new HashMap<>()); + .getResolutionResult() + .getRootComponent() + .map(root -> dependencyOptions(root, configurationName)) + .zip( + configuration.getIncoming().getArtifacts().getResolvedArtifacts(), + (dependencyOptions, artifacts) -> { final Map byFilePath = new HashMap<>(); - if (coordinates.isEmpty()) { - return byFilePath; - } for (final ResolvedArtifactResult artifact : artifacts) { - final String key = coordinateKey(artifact.getId().getComponentIdentifier()); - final String options = coordinates.get(key); + final ComponentIdentifier component = artifact.getId().getComponentIdentifier(); + final String options = dependencyOptions.get(component); if (options != null) { try { byFilePath.put(artifact.getFile().getCanonicalPath(), options); @@ -70,20 +68,37 @@ public static Provider> optionsByFilePath( }); } - /** - * Derives the coordinate key used to look up options for a resolved artifact. Module dependencies - * are keyed by {@code group:name} (version intentionally excluded so options survive version - * bumps), project dependencies by their project path, and anything else by its display name as a - * best-effort fallback. - */ - private static String coordinateKey(final ComponentIdentifier identifier) { - if (identifier instanceof ModuleComponentIdentifier) { - final ModuleComponentIdentifier module = (ModuleComponentIdentifier) identifier; - return module.getGroup() + ":" + module.getModule(); - } - if (identifier instanceof ProjectComponentIdentifier) { - return ((ProjectComponentIdentifier) identifier).getProjectPath(); + private static Map dependencyOptions( + final ResolvedComponentResult root, final String configurationName) { + final Map options = new HashMap<>(); + final Map priorities = new HashMap<>(); + for (final DependencyResult dependency : root.getDependencies()) { + if (!(dependency instanceof ResolvedDependencyResult)) { + continue; + } + final String value = + dependency.getRequested().getAttributes().getAttribute(AgentOptions.OPTIONS); + if (value == null) { + continue; + } + final ComponentIdentifier component = + ((ResolvedDependencyResult) dependency).getSelected().getId(); + final String declaredOn = + dependency.getRequested().getAttributes().getAttribute(AgentOptions.DECLARED_ON); + final int priority = configurationName.equals(declaredOn) ? 1 : 0; + final int previousPriority = priorities.getOrDefault(component, -1); + if (priority > previousPriority) { + options.put(component, value); + priorities.put(component, priority); + } else if (priority == previousPriority && !value.equals(options.get(component))) { + throw new GradleException( + "Conflicting javaagent options for " + + component.getDisplayName() + + " in configuration '" + + configurationName + + "'. Configure one option string per agent."); + } } - return identifier.getDisplayName(); + return options; } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt new file mode 100644 index 00000000..b2fcef03 --- /dev/null +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt @@ -0,0 +1,18 @@ +package com.ryandens.javaagent + +import org.gradle.api.artifacts.ModuleDependency + +/** + * Options for this agent dependency, appended after `=` in its `-javaagent` argument. + * + * Import `com.ryandens.javaagent.agentOptions` in a Kotlin build script and set + * `agentOptions = "12345:config.yaml"` inside a `javaagent` or `testJavaagent` dependency block. + * Declare the dependency in `runJavaagent`, `testJavaagent`, `distributionJavaagent`, or + * `jibJavaagent` to scope its options to the corresponding plugin. A `javaagent` dependency is + * inherited by all of these configurations; a direct declaration overrides inherited options. + */ +var ModuleDependency.agentOptions: String + get() = attributes.getAttribute(AgentOptions.OPTIONS) ?: "" + set(value) { + attributes { it.attribute(AgentOptions.OPTIONS, value) } + } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt index 8a068793..0fcece89 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -24,22 +24,27 @@ class JavaagentApplicationDistributionPlugin : */ private val destinationDirectory = "agent-libs" + companion object { + const val CONFIGURATION_NAME = "distributionJavaagent" + } + override fun dependentProjectPlugins(): Collection = setOf("application") override fun applyAfterJavaagentSetup( project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { - val extension = project.extensions.getByType(JavaagentExtension::class.java) + val distributionJavaagentConfiguration = + JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get(), extension.agentOptions) + AgentOptionsResolver.optionsByFilePath(distributionJavaagentConfiguration.get()) project.extensions .getByType(DistributionContainer::class.java) .named(DistributionPlugin.MAIN_DISTRIBUTION_NAME) .configure { distribution -> distribution.contents { copy -> - copy.from(javaagentConfiguration) { + copy.from(distributionJavaagentConfiguration) { it.into(destinationDirectory) } } @@ -55,10 +60,10 @@ class JavaagentApplicationDistributionPlugin : it.defaultJvmOpts = listOf("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar") .plus(it.defaultJvmOpts ?: listOf()) - it.inputs.files(javaagentConfiguration) - it.inputs.property("agentOptions", extension.agentOptions) + it.inputs.files(distributionJavaagentConfiguration) + it.inputs.property("agentOptions", optionsByFilePath) // custom start script generator that replaces the placeholder - val agentFiles: Provider> = javaagentConfiguration.map { configuration -> configuration.files } + val agentFiles: Provider> = distributionJavaagentConfiguration.map { configuration -> configuration.files } it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX, optionsByFilePath) it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS, optionsByFilePath) } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt index 300d4de7..e9cf7d16 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt @@ -13,15 +13,20 @@ import org.gradle.api.tasks.JavaExec class JavaagentApplicationRunPlugin : Plugin, JavaagentPlugin { + companion object { + const val CONFIGURATION_NAME = "runJavaagent" + } + override fun dependentProjectPlugins(): Collection = setOf("application") override fun applyAfterJavaagentSetup( project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { - val extension = project.extensions.getByType(JavaagentExtension::class.java) + val runJavaagentConfiguration = + JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get(), extension.agentOptions) + AgentOptionsResolver.optionsByFilePath(runJavaagentConfiguration.get()) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(ApplicationPlugin.TASK_RUN_NAME, JavaExec::class.java).configure { // The agent jars are passed to the JVM via a CommandLineArgumentProvider (see @@ -36,11 +41,11 @@ class JavaagentApplicationRunPlugin : // the start-script task. This requires every artifact added to the javaagent configuration to // carry its producing-task dependency (builtBy); consumers that synthesize a plain File must // preserve it (see the otel example's extendedAgent wiring). - it.inputs.files(javaagentConfiguration) - it.inputs.property("agentOptions", extension.agentOptions) + it.inputs.files(runJavaagentConfiguration) + it.inputs.property("agentOptions", optionsByFilePath) JavaForkOptionsConfigurer.configureJavaForkOptions( it, - javaagentConfiguration.map { configuration -> configuration.files }, + runJavaagentConfiguration.map { configuration -> configuration.files }, optionsByFilePath, ) } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt index 900e842e..55fe5ecf 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt @@ -83,13 +83,23 @@ class JavaagentAwareStartScriptGenerator( /** * Escapes a javaagent option value for safe inclusion in shell scripts. - * - For Unix: wraps in single quotes, escaping internal single quotes with '\'' + * - For Unix: escapes the xargs token, then the outer double-quoted shell assignment * - For Windows: doubles percent signs to prevent variable expansion */ private fun escapeOptionValue(value: String): String = when (platform) { - Platform.UNIX -> "'" + value.replace("'", "'\\''") + "'" - Platform.WINDOWS -> value.replace("%", "%%") + Platform.UNIX -> { + value + .replace("\"", "\"\\\"\"") + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + .replace("`", "\\`") + } + + Platform.WINDOWS -> { + value.replace("%", "%%") + } } /** diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt index 6f964a00..1d4f2630 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt @@ -1,7 +1,9 @@ package com.ryandens.javaagent +import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration /** * Creates javaagent configuration for usage in all plugins produced by this project @@ -9,7 +11,19 @@ import org.gradle.api.Project class JavaagentBasePlugin : Plugin { companion object { const val CONFIGURATION_NAME = "javaagent" - const val EXTENSION_NAME = "javaagent" + + /** Creates an isolated configuration for a plugin, inheriting shared agent declarations. */ + fun targetConfiguration( + project: Project, + name: String, + shared: NamedDomainObjectProvider, + ): NamedDomainObjectProvider = + project.configurations.register(name) { + it.isTransitive = false + it.isCanBeConsumed = false + it.extendsFrom(shared.get()) + AgentOptions.trackDeclarations(it) + } } override fun apply(project: Project) { @@ -17,11 +31,7 @@ class JavaagentBasePlugin : Plugin { project.configurations.register(CONFIGURATION_NAME) { // we expect javaagents to come as shaded JARs it.isTransitive = false + AgentOptions.trackDeclarations(it) } - // Register the extension used to configure agent options. Created once here because the base plugin is - // applied idempotently by JavaagentPlugin, so the `javaagent { }` block is available to every plugin - // (application, distribution, test, jib). The extension name does not collide with the `javaagent` - // configuration, which is reached via the `configurations` container. - project.extensions.create(EXTENSION_NAME, JavaagentExtension::class.java) } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt deleted file mode 100644 index bdf4ac93..00000000 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentExtension.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.ryandens.javaagent - -import org.gradle.api.provider.MapProperty - -/** - * Extension for configuring the javaagents attached by this plugin. - * - * Currently exposes [agentOptions], which associates agent option strings (the `=` portion of the - * `-javaagent:=` JVM argument, see the - * [java.lang.instrument package documentation](https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/package-summary.html#starting-an-agent-from-the-command-line-interface-heading)) - * with the agent they apply to. - * - * Options are keyed by the dependency coordinate the user declared in the `javaagent` (or `testJavaagent`) - * configuration, so they survive version bumps and do not depend on the resolved jar file name: - * - module dependencies are keyed by `group:name` (version is intentionally excluded) - * - project dependencies are keyed by their project path, e.g. `:simple-agent` - * - * ``` - * dependencies { - * javaagent 'io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0' - * } - * - * javaagent { - * agentOptions.put('io.prometheus.jmx:jmx_prometheus_javaagent', '12345:config.yaml') - * } - * ``` - */ -abstract class JavaagentExtension { - /** - * Maps an agent's dependency coordinate (`group:name` for module dependencies, project path for project - * dependencies) to the option string appended after `=` in the `-javaagent:=` argument. - */ - abstract val agentOptions: MapProperty -} diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt index c86b92df..10df710c 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt @@ -27,6 +27,7 @@ class JavaagentTestPlugin : project.configurations.register(CONFIGURATION_NAME) { // we expect javaagents to come as shaded JARs it.isTransitive = false + AgentOptions.trackDeclarations(it) it.extendsFrom(javaagentConfiguration.get()) it.resolutionStrategy { strategy -> strategy.eachDependency { dep -> @@ -46,14 +47,13 @@ class JavaagentTestPlugin : val enabled = calculateEnabled(project, extension) - val javaagentExtension = project.extensions.getByType(JavaagentExtension::class.java) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(javaagentTestConfiguration.get(), javaagentExtension.agentOptions) + AgentOptionsResolver.optionsByFilePath(javaagentTestConfiguration.get()) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(JavaPlugin.TEST_TASK_NAME, Test::class.java).configure { if (enabled.get()) { - it.inputs.property("agentOptions", javaagentExtension.agentOptions) + it.inputs.property("agentOptions", optionsByFilePath) JavaForkOptionsConfigurer.configureJavaForkOptions( it, javaagentTestConfiguration.map { configuration -> diff --git a/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt b/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt index 129a2f78..9bbec85e 100644 --- a/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt +++ b/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt @@ -3,6 +3,7 @@ package com.ryandens.javaagent import org.gradle.testfixtures.ProjectBuilder import kotlin.test.Test import kotlin.test.assertNotNull +import kotlin.test.assertNull /** * A simple unit test for the 'com.ryandens.javaagent.attach' plugin. @@ -16,5 +17,27 @@ class JavaagentApplicationPluginTest { // Verify the result assertNotNull(project.configurations.findByName("javaagent")) + assertNotNull(project.configurations.findByName("runJavaagent")) + assertNotNull(project.configurations.findByName("distributionJavaagent")) + assertNull(project.extensions.findByName("javaagent")) + } + + @Test fun `individual plugins create only their own agent configuration`() { + val plugins = + mapOf( + "com.ryandens.javaagent-application-run" to "runJavaagent", + "com.ryandens.javaagent-application-distribution" to "distributionJavaagent", + "com.ryandens.javaagent-test" to "testJavaagent", + ) + for ((pluginId, configuration) in plugins) { + val project = ProjectBuilder.builder().build() + project.plugins.apply("application") + project.plugins.apply(pluginId) + assertNotNull(project.configurations.findByName(configuration)) + for (other in plugins.values.filter { it != configuration }) { + assertNull(project.configurations.findByName(other)) + } + assertNull(project.extensions.findByName("javaagent")) + } } } From b2d5310450e24b590b3d990e06e652ab5c423c84 Mon Sep 17 00:00:00 2001 From: Ryan Dens Date: Sat, 17 Oct 2026 00:20:07 -0700 Subject: [PATCH 5/7] Preserve explicit agent artifacts and document resolution-rule migration --- README.md | 24 ++++ .../javaagent/jib/JavaagentJibPlugin.kt | 2 +- .../JavaagentPluginFunctionalTest.kt | 129 +++++++++++++++--- .../com/ryandens/javaagent/AgentOptions.java | 16 --- .../javaagent/AgentOptionsResolver.java | 100 ++++++++++---- .../JavaagentApplicationDistributionPlugin.kt | 2 +- .../JavaagentApplicationRunPlugin.kt | 2 +- .../ryandens/javaagent/JavaagentBasePlugin.kt | 2 - .../ryandens/javaagent/JavaagentTestPlugin.kt | 3 +- 9 files changed, 210 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index ce60bf84..51e67c7b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,30 @@ resolved agent. An empty string overrides inherited options with an empty option options on two direct declarations of the same resolved agent fail with an error. Agents without configured options are attached without an options suffix. +### Migrating shared resolution rules + +Plugin-specific configurations resolve their own dependencies. Gradle's `extendsFrom` shares dependency +declarations, constraints, and exclusions; it does not inherit resolution strategies or configuration +attributes. Previously, application run, distributions, and Jib resolved `javaagent` directly. If your +build customized `configurations.javaagent.resolutionStrategy`, move those rules to all relevant agent +configurations when upgrading: + +```kotlin +val agentConfigurations = setOf( + "javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent", +) + +configurations.matching { it.name in agentConfigurations }.configureEach { + resolutionStrategy.dependencySubstitution { + substitute(module("com.example:my-agent")).using(project(":my-agent")) + } +} +``` + +Use the same pattern for forced versions, `eachDependency` rules, or configuration attributes. +`configureEach` also covers configurations registered later by another agent plugin. To customize only +one context, configure that plugin's configuration directly, for example `configurations.runJavaagent`. + ## Jib integration [Jib](https://github.com/GoogleContainerTools/jib) is a build tool for containerizing Java applications without a Docker diff --git a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt index a4dd4ccb..632b1853 100644 --- a/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt +++ b/jib/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibPlugin.kt @@ -22,7 +22,7 @@ class JavaagentJibPlugin : Plugin { val javaagentConfiguration = project.configurations.named(JavaagentJibExtension.CONFIGURATION_NAME) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(javaagentConfiguration.get()) + AgentOptionsResolver.optionsByFilePath(javaagentConfiguration) // Transform to file-name keyed map for the Jib extension, which looks up by the copied file's name val optionsByFileName = optionsByFilePath.map { pathMap -> diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index 079d3e42..4ed79f6b 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -231,23 +231,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `can configure independent options in each plugin configuration`() { createJavaagentProject("") - // Publish the fixture agent locally so the consumer uses the external module overload, - // exactly as in the documented Kotlin DSL example. - File(functionalTestDir, "simple-agent/build.gradle.kts").appendText( - """ - - apply(plugin = "maven-publish") - group = "com.ryandens.fixture" - version = "1.0" - configure { - publications { - create("agent") { from(components["java"]) } - } - repositories { maven { url = uri(rootProject.file("repo")) } } - } - """, - ) - runBuild(listOf(":simple-agent:publish")) + publishAgentFixture("1.0") helloWorldDir.resolve("build.gradle").delete() helloWorldDir.resolve("build.gradle.kts").writeText( """ @@ -340,7 +324,8 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X buildScript.appendText( """ - configurations.all { + val agentConfigurations = setOf("javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent") + configurations.matching { it.name in agentConfigurations }.configureEach { resolutionStrategy.dependencySubstitution { substitute(module("com.ryandens.fixture:simple-agent")).using(project(":simple-agent")) } @@ -352,6 +337,89 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertTrue(substitutedResult.output.contains("Simple agent args: 12345:config.yaml")) } + @Test fun `classified explicit artifact and explicit configuration dependencies still work`() { + createJavaagentProject("") + publishAgentFixture("1.0") + helloWorldDir.resolve("build.gradle").delete() + helloWorldDir.resolve("build.gradle.kts").writeText( + """ + plugins { + application + id("com.ryandens.javaagent-application") + } + repositories { maven { url = uri(rootProject.file("repo")) } } + application { mainClass.set("com.ryandens.HelloWorld") } + dependencies { + javaagent("com.ryandens.fixture:simple-agent:1.0:instrumented") + runJavaagent("com.ryandens.fixture:simple-agent:1.0") { + artifact { name = "simple-agent"; type = "jar"; extension = "jar" } + } + } + """, + ) + val arguments = listOf("--configuration-cache", ":hello-world:run") + val result = runBuild(arguments) + assertEquals(2, result.output.lineSequence().count { it == "Hello from my simple agent!" }) + val cached = runBuild(arguments) + assertTrue(cached.output.contains("Reusing configuration cache.")) + assertEquals(2, cached.output.lineSequence().count { it == "Hello from my simple agent!" }) + + // Test the explicit project configuration separately: mixing it with the published module + // selects the project for both requests, whose outgoing variants do not publish the classifier. + val buildScript = helloWorldDir.resolve("build.gradle.kts") + buildScript.writeText( + buildScript.readText().substringBefore("dependencies {") + + """ + dependencies { + runJavaagent(project(path = ":simple-agent", configuration = "runtimeElements")) + } + """, + ) + val projectResult = runBuild(arguments) + assertEquals(1, projectResult.output.lineSequence().count { it == "Hello from my simple agent!" }) + val cachedProject = runBuild(arguments) + assertTrue(cachedProject.output.contains("Reusing configuration cache.")) + assertEquals(1, cachedProject.output.lineSequence().count { it == "Hello from my simple agent!" }) + } + + @Test fun `common force rules apply to every agent configuration when configured together`() { + createJavaagentProject("") + publishAgentFixture("1.0", "2.0") + helloWorldDir.resolve("build.gradle").delete() + helloWorldDir.resolve("build.gradle.kts").writeText( + """ + import com.ryandens.javaagent.agentOptions + + plugins { + application + id("com.ryandens.javaagent-application") + id("com.ryandens.javaagent-test") + } + repositories { maven { url = uri(rootProject.file("repo")) } } + val agentConfigurations = setOf("javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent") + configurations.matching { it.name in agentConfigurations }.configureEach { + resolutionStrategy.force("com.ryandens.fixture:simple-agent:2.0") + } + dependencies { + javaagent("com.ryandens.fixture:simple-agent:1.0") { agentOptions = "forced-options" } + } + application { mainClass.set("com.ryandens.HelloWorld") } + tasks.register("verifyAgentVersions") { + inputs.files(agentConfigurations.filter { it != "jibJavaagent" }.map { configurations.named(it) }) + doLast { + check(inputs.files.files.map { it.name }.toSet() == setOf("simple-agent-2.0.jar")) + } + } + """, + ) + val arguments = listOf("--configuration-cache", ":hello-world:verifyAgentVersions", ":hello-world:run") + val result = runBuild(arguments) + assertTrue(result.output.contains("Simple agent args: forced-options")) + val cached = runBuild(arguments) + assertTrue(cached.output.contains("Reusing configuration cache.")) + assertTrue(cached.output.contains("Simple agent args: forced-options")) + } + @Test fun `run only agents are absent from tests and packaged distributions`() { createJavaagentProject( """ @@ -535,6 +603,31 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar=12345: assertTrue(result.output.contains("Hello World!")) } + private fun publishAgentFixture(vararg versions: String) { + File(functionalTestDir, "simple-agent/build.gradle.kts").appendText( + """ + + apply(plugin = "maven-publish") + group = "com.ryandens.fixture" + version = providers.gradleProperty("fixtureVersion").get() + configure { + publications { + create("agent") { + from(components["java"]) + artifact(tasks.named("jar")) { classifier = "instrumented" } + } + } + repositories { maven { url = uri(rootProject.file("repo")) } } + } + """, + ) + for (version in versions) { + runBuild(listOf(":simple-agent:publish", "-PfixtureVersion=$version")) + } + // Keep a default version for subsequent consumer builds that include the fixture project. + functionalTestDir.resolve("gradle.properties").appendText("\nfixtureVersion=${versions.last()}\n") + } + private fun createJavaagentProject( dependencies: String, applicationDefaultJvmArgs: String = "['-Xmx256m']", diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java index ba44b08a..9898d32f 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java @@ -1,7 +1,5 @@ package com.ryandens.javaagent; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.artifacts.ModuleDependency; import org.gradle.api.attributes.Attribute; /** Dependency attributes used to retain agent options through copying and resolution. */ @@ -10,19 +8,5 @@ public final class AgentOptions { public static final Attribute OPTIONS = Attribute.of("com.ryandens.javaagent.options", String.class); - static final Attribute DECLARED_ON = - Attribute.of("com.ryandens.javaagent.declared-on", String.class); - private AgentOptions() {} - - /** Records declaration scope so direct options take precedence over inherited options. */ - public static void trackDeclarations(final Configuration configuration) { - final String name = configuration.getName(); - configuration - .getDependencies() - .withType(ModuleDependency.class) - .all( - dependency -> - dependency.attributes(attributes -> attributes.attribute(DECLARED_ON, name))); - } } diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java index 58749e23..12c4941d 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -4,10 +4,17 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import org.gradle.api.GradleException; import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ModuleDependency; +import org.gradle.api.artifacts.ProjectDependency; import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.component.ComponentSelector; +import org.gradle.api.artifacts.component.ModuleComponentSelector; +import org.gradle.api.artifacts.component.ProjectComponentSelector; import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.artifacts.result.ResolvedArtifactResult; import org.gradle.api.artifacts.result.ResolvedComponentResult; @@ -39,37 +46,73 @@ private AgentOptionsResolver() {} * configuration's resolved artifacts and the options map without touching {@code Project} at * execution time. * - * @param configuration the resolvable javaagent configuration whose artifacts should be matched + * @param configurationProvider the resolvable javaagent configuration whose artifacts should be + * matched * @return provider of a map from resolved artifact file path to its option string */ - public static Provider> optionsByFilePath(final Configuration configuration) { - final String configurationName = configuration.getName(); - return configuration - .getIncoming() - .getResolutionResult() - .getRootComponent() - .map(root -> dependencyOptions(root, configurationName)) - .zip( - configuration.getIncoming().getArtifacts().getResolvedArtifacts(), - (dependencyOptions, artifacts) -> { - final Map byFilePath = new HashMap<>(); - for (final ResolvedArtifactResult artifact : artifacts) { - final ComponentIdentifier component = artifact.getId().getComponentIdentifier(); - final String options = dependencyOptions.get(component); - if (options != null) { - try { - byFilePath.put(artifact.getFile().getCanonicalPath(), options); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - } - return byFilePath; - }); + public static Provider> optionsByFilePath( + final Provider configurationProvider) { + return configurationProvider.flatMap( + configuration -> { + final String configurationName = configuration.getName(); + final Map> directOptions = directOptions(configuration); + return configuration + .getIncoming() + .getResolutionResult() + .getRootComponent() + .map(root -> dependencyOptions(root, configurationName, directOptions)) + .zip( + configuration.getIncoming().getArtifacts().getResolvedArtifacts(), + (dependencyOptions, artifacts) -> { + final Map byFilePath = new HashMap<>(); + for (final ResolvedArtifactResult artifact : artifacts) { + final ComponentIdentifier component = + artifact.getId().getComponentIdentifier(); + final String options = dependencyOptions.get(component); + if (options != null) { + try { + byFilePath.put(artifact.getFile().getCanonicalPath(), options); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + return byFilePath; + }); + }); + } + + private static Map> directOptions(final Configuration configuration) { + final Map> options = new HashMap<>(); + for (final ModuleDependency dependency : + configuration.getDependencies().withType(ModuleDependency.class)) { + final String value = dependency.getAttributes().getAttribute(AgentOptions.OPTIONS); + if (value != null) { + final String key = + dependency instanceof ProjectDependency + ? "project:" + ((ProjectDependency) dependency).getPath() + : "module:" + dependency.getGroup() + ":" + dependency.getName(); + options.computeIfAbsent(key, unused -> new HashSet<>()).add(value); + } + } + return options; + } + + private static String requestedKey(final ComponentSelector selector) { + if (selector instanceof ProjectComponentSelector) { + return "project:" + ((ProjectComponentSelector) selector).getProjectPath(); + } + if (selector instanceof ModuleComponentSelector) { + final ModuleComponentSelector module = (ModuleComponentSelector) selector; + return "module:" + module.getGroup() + ":" + module.getModule(); + } + return selector.getDisplayName(); } private static Map dependencyOptions( - final ResolvedComponentResult root, final String configurationName) { + final ResolvedComponentResult root, + final String configurationName, + final Map> directOptions) { final Map options = new HashMap<>(); final Map priorities = new HashMap<>(); for (final DependencyResult dependency : root.getDependencies()) { @@ -83,9 +126,8 @@ private static Map dependencyOptions( } final ComponentIdentifier component = ((ResolvedDependencyResult) dependency).getSelected().getId(); - final String declaredOn = - dependency.getRequested().getAttributes().getAttribute(AgentOptions.DECLARED_ON); - final int priority = configurationName.equals(declaredOn) ? 1 : 0; + final Set directValues = directOptions.get(requestedKey(dependency.getRequested())); + final int priority = directValues != null && directValues.contains(value) ? 1 : 0; final int previousPriority = priorities.getOrDefault(component, -1); if (priority > previousPriority) { options.put(component, value); diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt index 0fcece89..9ea96742 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -37,7 +37,7 @@ class JavaagentApplicationDistributionPlugin : val distributionJavaagentConfiguration = JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(distributionJavaagentConfiguration.get()) + AgentOptionsResolver.optionsByFilePath(distributionJavaagentConfiguration) project.extensions .getByType(DistributionContainer::class.java) diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt index e9cf7d16..f9bd1567 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt @@ -26,7 +26,7 @@ class JavaagentApplicationRunPlugin : val runJavaagentConfiguration = JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(runJavaagentConfiguration.get()) + AgentOptionsResolver.optionsByFilePath(runJavaagentConfiguration) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(ApplicationPlugin.TASK_RUN_NAME, JavaExec::class.java).configure { // The agent jars are passed to the JVM via a CommandLineArgumentProvider (see diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt index 1d4f2630..5705a83d 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentBasePlugin.kt @@ -22,7 +22,6 @@ class JavaagentBasePlugin : Plugin { it.isTransitive = false it.isCanBeConsumed = false it.extendsFrom(shared.get()) - AgentOptions.trackDeclarations(it) } } @@ -31,7 +30,6 @@ class JavaagentBasePlugin : Plugin { project.configurations.register(CONFIGURATION_NAME) { // we expect javaagents to come as shaded JARs it.isTransitive = false - AgentOptions.trackDeclarations(it) } } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt index 10df710c..305ab64b 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt @@ -27,7 +27,6 @@ class JavaagentTestPlugin : project.configurations.register(CONFIGURATION_NAME) { // we expect javaagents to come as shaded JARs it.isTransitive = false - AgentOptions.trackDeclarations(it) it.extendsFrom(javaagentConfiguration.get()) it.resolutionStrategy { strategy -> strategy.eachDependency { dep -> @@ -48,7 +47,7 @@ class JavaagentTestPlugin : val enabled = calculateEnabled(project, extension) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(javaagentTestConfiguration.get()) + AgentOptionsResolver.optionsByFilePath(javaagentTestConfiguration) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(JavaPlugin.TEST_TASK_NAME, Test::class.java).configure { From 8375b7dfc1d7c880d9a10dcbcde3074304f2bb7e Mon Sep 17 00:00:00 2001 From: Ryan Dens Date: Sat, 17 Oct 2026 00:20:07 -0700 Subject: [PATCH 6/7] Resolve agent options against their declared artifacts --- .../JavaagentPluginFunctionalTest.kt | 64 +++++++- .../javaagent/AgentOptionsResolver.java | 144 ++++++++++++++---- 2 files changed, 178 insertions(+), 30 deletions(-) diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index 4ed79f6b..b22b1c0d 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -340,6 +340,8 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `classified explicit artifact and explicit configuration dependencies still work`() { createJavaagentProject("") publishAgentFixture("1.0") + val repository = functionalTestDir.resolve("repo/com/ryandens/fixture/simple-agent/1.0") + repository.resolve("simple-agent-1.0.jar").copyTo(repository.resolve("alternate-1.0.jar")) helloWorldDir.resolve("build.gradle").delete() helloWorldDir.resolve("build.gradle.kts").writeText( """ @@ -364,9 +366,32 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertTrue(cached.output.contains("Reusing configuration cache.")) assertEquals(2, cached.output.lineSequence().count { it == "Hello from my simple agent!" }) + val buildScript = helloWorldDir.resolve("build.gradle.kts") + buildScript.writeText( + "import com.ryandens.javaagent.agentOptions\n" + + buildScript + .readText() + .replace( + "artifact { name = \"simple-agent\"; type = \"jar\"; extension = \"jar\" }", + "agentOptions = \"main-only-options\"", + ) + + """ + dependencies { + runJavaagent("com.ryandens.fixture:simple-agent:1.0") { + artifact { name = "alternate"; type = "jar"; extension = "jar" } + } + } + """, + ) + val withOptions = runBuild(arguments) + assertEquals(3, withOptions.output.lineSequence().count { it == "Hello from my simple agent!" }) + assertEquals(1, withOptions.output.lineSequence().count { it == "Simple agent args: main-only-options" }) + val cachedOptions = runBuild(arguments) + assertTrue(cachedOptions.output.contains("Reusing configuration cache.")) + assertEquals(1, cachedOptions.output.lineSequence().count { it == "Simple agent args: main-only-options" }) + // Test the explicit project configuration separately: mixing it with the published module // selects the project for both requests, whose outgoing variants do not publish the classifier. - val buildScript = helloWorldDir.resolve("build.gradle.kts") buildScript.writeText( buildScript.readText().substringBefore("dependencies {") + """ @@ -382,6 +407,38 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertEquals(1, cachedProject.output.lineSequence().count { it == "Hello from my simple agent!" }) } + @Test fun `direct options added before resolution override inherited options`() { + createJavaagentProject( + """ + javaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'shared-options') } + } + """, + ) + helloWorldDir.resolve("build.gradle").appendText( + """ + + configurations.runJavaagent.incoming.beforeResolve { + project.dependencies.add('runJavaagent', project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'late-direct-options') } + } + } + // Resolve while the configuration is still mutable. Gradle rejects dependency mutation + // in beforeResolve after task dependency discovery has observed a configuration. + def resolvedOptions = com.ryandens.javaagent.AgentOptionsResolver + .optionsByFilePath(configurations.named('runJavaagent')).get() + assert resolvedOptions.values().toSet() == ['late-direct-options'].toSet() + """, + ) + val arguments = listOf("--configuration-cache", ":hello-world:run") + val result = runBuild(arguments) + assertTrue(result.output.contains("Simple agent args: late-direct-options")) + assertFalse(result.output.contains("Simple agent args: shared-options")) + val cached = runBuild(arguments) + assertTrue(cached.output.contains("Reusing configuration cache.")) + assertTrue(cached.output.contains("Simple agent args: late-direct-options")) + } + @Test fun `common force rules apply to every agent configuration when configured together`() { createJavaagentProject("") publishAgentFixture("1.0", "2.0") @@ -396,6 +453,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X id("com.ryandens.javaagent-test") } repositories { maven { url = uri(rootProject.file("repo")) } } + dependencyLocking { lockAllConfigurations() } val agentConfigurations = setOf("javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent") configurations.matching { it.name in agentConfigurations }.configureEach { resolutionStrategy.force("com.ryandens.fixture:simple-agent:2.0") @@ -413,8 +471,10 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X """, ) val arguments = listOf("--configuration-cache", ":hello-world:verifyAgentVersions", ":hello-world:run") - val result = runBuild(arguments) + val result = runBuild(arguments + "--write-locks") assertTrue(result.output.contains("Simple agent args: forced-options")) + assertFalse(helloWorldDir.resolve("gradle.lockfile").readText().contains("Copy")) + runBuild(arguments) val cached = runBuild(arguments) assertTrue(cached.output.contains("Reusing configuration cache.")) assertTrue(cached.output.contains("Simple agent args: forced-options")) diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java index 12c4941d..1b04dd44 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -13,7 +13,9 @@ import org.gradle.api.artifacts.ProjectDependency; import org.gradle.api.artifacts.component.ComponentIdentifier; import org.gradle.api.artifacts.component.ComponentSelector; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; import org.gradle.api.artifacts.component.ModuleComponentSelector; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; import org.gradle.api.artifacts.component.ProjectComponentSelector; import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.artifacts.result.ResolvedArtifactResult; @@ -52,46 +54,132 @@ private AgentOptionsResolver() {} */ public static Provider> optionsByFilePath( final Provider configurationProvider) { - return configurationProvider.flatMap( + return configurationProvider.map( configuration -> { - final String configurationName = configuration.getName(); - final Map> directOptions = directOptions(configuration); - return configuration - .getIncoming() - .getResolutionResult() - .getRootComponent() - .map(root -> dependencyOptions(root, configurationName, directOptions)) - .zip( - configuration.getIncoming().getArtifacts().getResolvedArtifacts(), - (dependencyOptions, artifacts) -> { - final Map byFilePath = new HashMap<>(); - for (final ResolvedArtifactResult artifact : artifacts) { - final ComponentIdentifier component = - artifact.getId().getComponentIdentifier(); - final String options = dependencyOptions.get(component); - if (options != null) { - try { - byFilePath.put(artifact.getFile().getCanonicalPath(), options); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } + // Resolve first: callbacks may add direct declarations that override inherited options. + final ResolvedComponentResult root = + configuration.getIncoming().getResolutionResult().getRootComponent().get(); + final Map options = + dependencyOptions(root, configuration.getName(), directOptions(configuration)); + final Map byFilePath = new HashMap<>(); + if (options.isEmpty()) { + return byFilePath; + } + final Map> requests = new HashMap<>(); + for (final DependencyResult dependency : root.getDependencies()) { + if (dependency instanceof ResolvedDependencyResult) { + final String value = + dependency.getRequested().getAttributes().getAttribute(AgentOptions.OPTIONS); + final ComponentIdentifier selected = + ((ResolvedDependencyResult) dependency).getSelected().getId(); + if (value != null && value.equals(options.get(selected))) { + requests + .computeIfAbsent( + requestedKey(dependency.getRequested()), unused -> new HashSet<>()) + .add(value); + } + } + } + // Resolving just the declarations that own options identifies their actual artifacts. + // Component-wide artifact views also include optionless classifier/alternate requests. + final Configuration optionConfiguration = + configuration.copyRecursive( + dependency -> { + if (!(dependency instanceof ModuleDependency)) { + return false; } - return byFilePath; + final ModuleDependency module = (ModuleDependency) dependency; + final Set values = requests.get(dependencyKey(module)); + return values != null + && values.contains( + module.getAttributes().getAttribute(AgentOptions.OPTIONS)); }); + preserveSelections(configuration, optionConfiguration, root); + // The original configuration already validated locks; the unnamed copy uses those + // selections. + optionConfiguration.getResolutionStrategy().deactivateDependencyLocking(); + final Map originalArtifacts = new HashMap<>(); + for (final ResolvedArtifactResult artifact : + configuration.getIncoming().getArtifacts().getArtifacts()) { + originalArtifacts.put( + canonicalPath(artifact), artifact.getId().getComponentIdentifier()); + } + for (final ResolvedArtifactResult artifact : + optionConfiguration.getIncoming().getArtifacts().getArtifacts()) { + final String path = canonicalPath(artifact); + final ComponentIdentifier component = artifact.getId().getComponentIdentifier(); + final String value = options.get(component); + if (value == null || !component.equals(originalArtifacts.get(path))) { + throw new GradleException( + "Agent option resolution changed the selected artifact: " + + artifact.getId().getDisplayName()); + } + byFilePath.put(path, value); + } + return byFilePath; }); } + private static String canonicalPath(final ResolvedArtifactResult artifact) { + try { + return artifact.getFile().getCanonicalPath(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static void preserveSelections( + final Configuration original, final Configuration copy, final ResolvedComponentResult root) { + final Map selections = new HashMap<>(); + for (final DependencyResult dependency : + original.getIncoming().getResolutionResult().getAllDependencies()) { + if (dependency instanceof ResolvedDependencyResult) { + selections.put( + requestedKey(dependency.getRequested()), + ((ResolvedDependencyResult) dependency).getSelected().getId()); + } + } + final ProjectComponentIdentifier rootId = (ProjectComponentIdentifier) root.getId(); + copy.getResolutionStrategy() + .dependencySubstitution( + substitutions -> + substitutions.all( + details -> { + final ComponentIdentifier selected = + selections.get(requestedKey(details.getRequested())); + if (selected instanceof ModuleComponentIdentifier) { + final ModuleComponentIdentifier module = + (ModuleComponentIdentifier) selected; + details.useTarget( + substitutions.module( + module.getGroup() + + ":" + + module.getModule() + + ":" + + module.getVersion())); + } else if (selected instanceof ProjectComponentIdentifier) { + final ProjectComponentIdentifier project = + (ProjectComponentIdentifier) selected; + if (project.getBuild().equals(rootId.getBuild())) { + details.useTarget(substitutions.project(project.getProjectPath())); + } + } + })); + } + + private static String dependencyKey(final ModuleDependency dependency) { + return dependency instanceof ProjectDependency + ? "project:" + ((ProjectDependency) dependency).getPath() + : "module:" + dependency.getGroup() + ":" + dependency.getName(); + } + private static Map> directOptions(final Configuration configuration) { final Map> options = new HashMap<>(); for (final ModuleDependency dependency : configuration.getDependencies().withType(ModuleDependency.class)) { final String value = dependency.getAttributes().getAttribute(AgentOptions.OPTIONS); if (value != null) { - final String key = - dependency instanceof ProjectDependency - ? "project:" + ((ProjectDependency) dependency).getPath() - : "module:" + dependency.getGroup() + ":" + dependency.getName(); + final String key = dependencyKey(dependency); options.computeIfAbsent(key, unused -> new HashSet<>()).add(value); } } From 9b9ac2067bf029847c3ea6d61e55e773d2803085 Mon Sep 17 00:00:00 2001 From: Ryan Dens Date: Sat, 17 Oct 2026 12:17:36 -0700 Subject: [PATCH 7/7] Align application configuration names and inherit downstream option overrides --- README.md | 47 ++++-- .../JavaagentJibExtensionFunctionalTest.kt | 14 +- .../javaagent/jib/JavaagentJibPluginTest.kt | 4 +- .../JavaagentPluginFunctionalTest.kt | 135 ++++++++++++++++-- .../javaagent/AgentOptionsResolver.java | 89 ++++++++---- .../javaagent/DependencyAgentOptions.kt | 2 +- .../JavaagentApplicationDistributionPlugin.kt | 12 +- .../JavaagentApplicationRunPlugin.kt | 10 +- .../JavaagentApplicationPluginTest.kt | 8 +- 9 files changed, 242 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 51e67c7b..509e89ab 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,14 @@ execution or packaging context: | Plugin | Dependency configuration | Used by | | --- | --- | --- | -| `com.ryandens.javaagent-application-run` | `runJavaagent` | Local application `run` | +| `com.ryandens.javaagent-application-run` | `applicationRunJavaagent` | Local application `run` | | `com.ryandens.javaagent-test` | `testJavaagent` | Java `test` | -| `com.ryandens.javaagent-application-distribution` | `distributionJavaagent` | `installDist`, `distTar`, and `distZip` launchers | +| `com.ryandens.javaagent-application-distribution` | `applicationDistributionJavaagent` | `installDist`, `distTar`, and `distZip` launchers | | `com.ryandens.javaagent-jib` | `jibJavaagent` | Jib container image entrypoints | The `com.ryandens.javaagent-application` convenience plugin applies both application plugins and -provides both `runJavaagent` and `distributionJavaagent`. Apply only the plugins needed for your -project; each registers its own configuration. +provides both `applicationRunJavaagent` and `applicationDistributionJavaagent`. Apply only the plugins +needed for your project; each registers its own configuration. In Kotlin DSL, import `agentOptions` and set it on each dependency: @@ -99,13 +99,13 @@ plugins { } dependencies { - runJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { + applicationRunJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { agentOptions = "12345:config/local.yaml" } testJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { agentOptions = "12346:config/test.yaml" } - distributionJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { + applicationDistributionJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { agentOptions = "12345:config/distribution.yaml" } jibJavaagent("io.prometheus.jmx:jmx_prometheus_javaagent:0.20.0") { @@ -115,15 +115,38 @@ dependencies { ``` Agents declared only in a plugin's configuration are included only in that context. Project -dependencies work the same way, for example `runJavaagent(project(":my-agent")) { agentOptions = "..." }`. +dependencies work the same way, for example `applicationRunJavaagent(project(":my-agent")) { agentOptions = "..." }`. Tar, zip, and installed distributions share options because they package the same generated launchers. The existing `javaagent` dependency configuration is shared: declarations there are inherited by each plugin-specific configuration. Use it when the same agent and options should apply to every enabled context. Options explicitly set on a plugin-specific declaration override inherited options for that -resolved agent. An empty string overrides inherited options with an empty options suffix. Conflicting -options on two direct declarations of the same resolved agent fail with an error. Agents without -configured options are attached without an options suffix. +resolved agent. This follows the configuration hierarchy: descendant declarations override ancestors. +An empty string overrides inherited options with an empty options suffix. Conflicting options in the +same configuration or in unrelated parent configurations fail unless a descendant supplies an override. +Agents without configured options are attached without an options suffix. + +Every downstream configuration can override options inherited from `javaagent`. For example, to +override shared options for tests: + +```kotlin +dependencies { + javaagent("com.example:my-agent:1.0") { + agentOptions = "shared-options" + } + testJavaagent("com.example:my-agent:1.0") { + agentOptions = "test-options" + } +} +``` + +Application runs, distributions, and Jib images use `shared-options`; tests use `test-options`. +Declare overrides in `applicationRunJavaagent`, `applicationDistributionJavaagent`, or `jibJavaagent` +in the same way to customize those contexts. The same rule applies to custom configurations extending +`javaagent`. +Omit `agentOptions` from the `javaagent` declaration to attach the agent everywhere with +options only in tests. With only the shared declaration, every enabled context uses its options. +The base plugin registers `javaagent`; the context plugins inherit from it and attach the agents. ### Migrating shared resolution rules @@ -135,7 +158,7 @@ configurations when upgrading: ```kotlin val agentConfigurations = setOf( - "javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent", + "javaagent", "applicationRunJavaagent", "testJavaagent", "applicationDistributionJavaagent", "jibJavaagent", ) configurations.matching { it.name in agentConfigurations }.configureEach { @@ -147,7 +170,7 @@ configurations.matching { it.name in agentConfigurations }.configureEach { Use the same pattern for forced versions, `eachDependency` rules, or configuration attributes. `configureEach` also covers configurations registered later by another agent plugin. To customize only -one context, configure that plugin's configuration directly, for example `configurations.runJavaagent`. +one context, configure that plugin's configuration directly, for example `configurations.applicationRunJavaagent`. ## Jib integration diff --git a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt index e19bede1..dbf641ec 100644 --- a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt +++ b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt @@ -30,9 +30,11 @@ class JavaagentJibExtensionFunctionalTest { functionalTestDir.deleteRecursively() } - @Test fun `can create docker image using jib`() { + @Test fun `can create docker image using shared agent options`() { val dependencies = """ - javaagent project(':simple-agent') + javaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'shared-options') } + } runtimeOnly 'commons-lang:commons-lang:2.6' """ @@ -51,7 +53,7 @@ class JavaagentJibExtensionFunctionalTest { while (entry != null) { if ("config.json" == entry.name) { val json = ais.readBytes().toString(Charsets.UTF_8) - assertTrue(json.contains("-javaagent:/opt/jib-agents/simple-agent.jar")) + assertTrue(json.contains("-javaagent:/opt/jib-agents/simple-agent.jar=shared-options")) } entry = ais.nextEntry } @@ -106,8 +108,9 @@ class JavaagentJibExtensionFunctionalTest { } application { mainClass.set("com.ryandens.HelloWorld") } dependencies { - runJavaagent(project(":simple-agent")) { agentOptions = "local-only-options" } - distributionJavaagent(project(":simple-agent")) { agentOptions = "distribution-only-options" } + javaagent(project(":simple-agent")) { agentOptions = "shared-options" } + applicationRunJavaagent(project(":simple-agent")) { agentOptions = "local-only-options" } + applicationDistributionJavaagent(project(":simple-agent")) { agentOptions = "distribution-only-options" } jibJavaagent(project(":simple-agent")) { agentOptions = "12345:dependency-config.yaml" } @@ -131,6 +134,7 @@ class JavaagentJibExtensionFunctionalTest { assertTrue(json.contains("-javaagent:/opt/jib-agents/simple-agent.jar=12345:dependency-config.yaml")) assertFalse(json.contains("local-only-options")) assertFalse(json.contains("distribution-only-options")) + assertFalse(json.contains("shared-options")) } entry = ais.nextEntry } diff --git a/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt b/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt index ae832097..49012bcf 100644 --- a/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt +++ b/jib/src/test/kotlin/com/ryandens/javaagent/jib/JavaagentJibPluginTest.kt @@ -24,8 +24,8 @@ class JavaagentJibPluginTest { // Verify the result assertNotNull(project.configurations.findByName("javaagent")) assertNotNull(project.configurations.findByName("jibJavaagent")) - assertNull(project.configurations.findByName("runJavaagent")) - assertNull(project.configurations.findByName("distributionJavaagent")) + assertNull(project.configurations.findByName("applicationRunJavaagent")) + assertNull(project.configurations.findByName("applicationDistributionJavaagent")) assertNull(project.configurations.findByName("testJavaagent")) assertNull(project.extensions.findByName("javaagent")) diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index b22b1c0d..e118a353 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -215,7 +215,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `can pass agent options to application run task`() { val dependencies = """ - runJavaagent(project(':simple-agent')) { + applicationRunJavaagent(project(':simple-agent')) { attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } } """ @@ -229,6 +229,113 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X assertTrue(result.output.contains("Simple agent args: 12345:config.yaml")) } + @Test fun `shared agent options are inherited and can be overridden only for tests`() { + val sharedDependency = """ + javaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'shared-options') } + } + """ + createJavaagentProject(sharedDependency) + val buildScript = helloWorldDir.resolve("build.gradle") + buildScript.appendText("\ntest { testLogging.showStandardStreams = true }\n") + val applicationTasks = listOf("run", "execStartScript", "distTar", "distZip") + val sharedApplication = runBuild(applicationTasks) + assertEquals(2, sharedApplication.output.lineSequence().count { it.trim() == "Simple agent args: shared-options" }) + val sharedTests = runBuild(listOf("test")) + assertTrue(sharedTests.output.contains("Simple agent args: shared-options")) + + buildScript.appendText( + """ + + dependencies { + testJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'test-options') } + } + } + """, + ) + val arguments = applicationTasks + "test" + val overridden = runBuild(arguments) + assertEquals(2, overridden.output.lineSequence().count { it.trim() == "Simple agent args: shared-options" }) + assertTrue(overridden.output.contains("Simple agent args: test-options")) + val launcher = helloWorldDir.resolve("build/scripts/hello-world$scriptExtension").readText() + assertTrue(launcher.contains("shared-options")) + assertFalse(launcher.contains("test-options")) + + // A shared agent without options remains attached everywhere; only tests receive options. + buildScript.writeText(buildScript.readText().replace(sharedDependency, "javaagent project(':simple-agent')")) + val testOnlyOptions = runBuild(arguments + "--rerun-tasks") + assertEquals(3, testOnlyOptions.output.lineSequence().count { it.trim() == "Hello from my simple agent!" }) + assertEquals(1, testOnlyOptions.output.lineSequence().count { it.trim().startsWith("Simple agent args:") }) + assertTrue(testOnlyOptions.output.contains("Simple agent args: test-options")) + val optionlessLauncher = helloWorldDir.resolve("build/scripts/hello-world$scriptExtension").readText() + assertFalse(optionlessLauncher.contains("shared-options")) + assertFalse(optionlessLauncher.contains("test-options")) + } + + @Test fun `custom downstream configurations override ancestor options and reject sibling conflicts`() { + createJavaagentProject( + """ + javaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'shared-options') } + } + """, + ) + val buildScript = helloWorldDir.resolve("build.gradle") + buildScript.appendText( + """ + + configurations { + developmentJavaagent { extendsFrom javaagent } + applicationRunJavaagent { extendsFrom developmentJavaagent } + } + dependencies { + developmentJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'development-options') } + } + } + """, + ) + val arguments = listOf("--configuration-cache", "run") + val inherited = runBuild(arguments) + assertTrue(inherited.output.contains("Simple agent args: development-options")) + assertFalse(inherited.output.contains("Simple agent args: shared-options")) + val cached = runBuild(arguments) + assertTrue(cached.output.contains("Reusing configuration cache.")) + assertTrue(cached.output.contains("Simple agent args: development-options")) + + buildScript.appendText( + """ + + configurations { + diagnosticJavaagent { extendsFrom javaagent } + applicationRunJavaagent { extendsFrom diagnosticJavaagent } + } + dependencies { + diagnosticJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'diagnostic-options') } + } + } + """, + ) + val conflict = runBuild(arguments, expectFailure = true) + assertTrue(conflict.output.contains("Conflicting javaagent options for")) + buildScript.appendText( + """ + + dependencies { + applicationRunJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'run-options') } + } + } + """, + ) + val overridden = runBuild(arguments) + assertTrue(overridden.output.contains("Simple agent args: run-options")) + assertFalse(overridden.output.contains("Simple agent args: development-options")) + assertFalse(overridden.output.contains("Simple agent args: diagnostic-options")) + } + @Test fun `can configure independent options in each plugin configuration`() { createJavaagentProject("") publishAgentFixture("1.0") @@ -251,10 +358,10 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X javaagent("com.ryandens.fixture:simple-agent:1.0") { agentOptions = "shared-options" } - runJavaagent("com.ryandens.fixture:simple-agent:1.0") { + applicationRunJavaagent("com.ryandens.fixture:simple-agent:1.0") { agentOptions = "12345:config.yaml" } - distributionJavaagent("com.ryandens.fixture:simple-agent:1.0") { + applicationDistributionJavaagent("com.ryandens.fixture:simple-agent:1.0") { agentOptions = "distribution-options" } testJavaagent("com.ryandens.fixture:simple-agent:1.0") { @@ -324,7 +431,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X buildScript.appendText( """ - val agentConfigurations = setOf("javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent") + val agentConfigurations = setOf("javaagent", "applicationRunJavaagent", "testJavaagent", "applicationDistributionJavaagent", "jibJavaagent") configurations.matching { it.name in agentConfigurations }.configureEach { resolutionStrategy.dependencySubstitution { substitute(module("com.ryandens.fixture:simple-agent")).using(project(":simple-agent")) @@ -353,7 +460,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X application { mainClass.set("com.ryandens.HelloWorld") } dependencies { javaagent("com.ryandens.fixture:simple-agent:1.0:instrumented") - runJavaagent("com.ryandens.fixture:simple-agent:1.0") { + applicationRunJavaagent("com.ryandens.fixture:simple-agent:1.0") { artifact { name = "simple-agent"; type = "jar"; extension = "jar" } } } @@ -377,7 +484,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X ) + """ dependencies { - runJavaagent("com.ryandens.fixture:simple-agent:1.0") { + applicationRunJavaagent("com.ryandens.fixture:simple-agent:1.0") { artifact { name = "alternate"; type = "jar"; extension = "jar" } } } @@ -396,7 +503,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X buildScript.readText().substringBefore("dependencies {") + """ dependencies { - runJavaagent(project(path = ":simple-agent", configuration = "runtimeElements")) + applicationRunJavaagent(project(path = ":simple-agent", configuration = "runtimeElements")) } """, ) @@ -418,15 +525,15 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X helloWorldDir.resolve("build.gradle").appendText( """ - configurations.runJavaagent.incoming.beforeResolve { - project.dependencies.add('runJavaagent', project(':simple-agent')) { + configurations.applicationRunJavaagent.incoming.beforeResolve { + project.dependencies.add('applicationRunJavaagent', project(':simple-agent')) { attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'late-direct-options') } } } // Resolve while the configuration is still mutable. Gradle rejects dependency mutation // in beforeResolve after task dependency discovery has observed a configuration. def resolvedOptions = com.ryandens.javaagent.AgentOptionsResolver - .optionsByFilePath(configurations.named('runJavaagent')).get() + .optionsByFilePath(configurations.named('applicationRunJavaagent')).get() assert resolvedOptions.values().toSet() == ['late-direct-options'].toSet() """, ) @@ -454,7 +561,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X } repositories { maven { url = uri(rootProject.file("repo")) } } dependencyLocking { lockAllConfigurations() } - val agentConfigurations = setOf("javaagent", "runJavaagent", "testJavaagent", "distributionJavaagent", "jibJavaagent") + val agentConfigurations = setOf("javaagent", "applicationRunJavaagent", "testJavaagent", "applicationDistributionJavaagent", "jibJavaagent") configurations.matching { it.name in agentConfigurations }.configureEach { resolutionStrategy.force("com.ryandens.fixture:simple-agent:2.0") } @@ -483,7 +590,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `run only agents are absent from tests and packaged distributions`() { createJavaagentProject( """ - runJavaagent(project(':simple-agent')) { + applicationRunJavaagent(project(':simple-agent')) { attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'local-only') } } """, @@ -562,7 +669,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X @Test fun `can pass agent options to application distribution start script`() { val dependencies = """ - distributionJavaagent(project(':simple-agent')) { + applicationDistributionJavaagent(project(':simple-agent')) { attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } } """ @@ -599,7 +706,7 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar=12345: val groovyOptions = options.replace("\\", "\\\\").replace("'", "\\'") createJavaagentProject( """ - distributionJavaagent(project(':simple-agent')) { + applicationDistributionJavaagent(project(':simple-agent')) { attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '$groovyOptions') } } """, diff --git a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java index 1b04dd44..1d98a65e 100644 --- a/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -41,8 +41,8 @@ private AgentOptionsResolver() {} /** * Builds a {@code filePath -> options} map from the resolved artifacts of the provided - * configuration. Direct declarations override inherited declarations. Only agents that have - * options declared appear in the resulting map. + * configuration. Declarations in descendant configurations override ancestor declarations. Only + * agents that have options declared appear in the resulting map. * *

The returned {@link Provider} is lazy and configuration-cache safe: it reads the * configuration's resolved artifacts and the options map without touching {@code Project} at @@ -59,8 +59,7 @@ public static Provider> optionsByFilePath( // Resolve first: callbacks may add direct declarations that override inherited options. final ResolvedComponentResult root = configuration.getIncoming().getResolutionResult().getRootComponent().get(); - final Map options = - dependencyOptions(root, configuration.getName(), directOptions(configuration)); + final Map options = dependencyOptions(root, configuration); final Map byFilePath = new HashMap<>(); if (options.isEmpty()) { return byFilePath; @@ -173,14 +172,19 @@ private static String dependencyKey(final ModuleDependency dependency) { : "module:" + dependency.getGroup() + ":" + dependency.getName(); } - private static Map> directOptions(final Configuration configuration) { - final Map> options = new HashMap<>(); - for (final ModuleDependency dependency : - configuration.getDependencies().withType(ModuleDependency.class)) { - final String value = dependency.getAttributes().getAttribute(AgentOptions.OPTIONS); - if (value != null) { - final String key = dependencyKey(dependency); - options.computeIfAbsent(key, unused -> new HashSet<>()).add(value); + private static Map>> declaredOptions( + final Configuration configuration) { + final Map>> options = new HashMap<>(); + for (final Configuration scope : configuration.getHierarchy()) { + for (final ModuleDependency dependency : + scope.getDependencies().withType(ModuleDependency.class)) { + final String value = dependency.getAttributes().getAttribute(AgentOptions.OPTIONS); + if (value != null) { + options + .computeIfAbsent(dependencyKey(dependency), unused -> new HashMap<>()) + .computeIfAbsent(scope, unused -> new HashSet<>()) + .add(value); + } } } return options; @@ -198,11 +202,10 @@ private static String requestedKey(final ComponentSelector selector) { } private static Map dependencyOptions( - final ResolvedComponentResult root, - final String configurationName, - final Map> directOptions) { - final Map options = new HashMap<>(); - final Map priorities = new HashMap<>(); + final ResolvedComponentResult root, final Configuration configuration) { + final Map>> declarations = + declaredOptions(configuration); + final Map>> candidates = new HashMap<>(); for (final DependencyResult dependency : root.getDependencies()) { if (!(dependency instanceof ResolvedDependencyResult)) { continue; @@ -214,21 +217,47 @@ private static Map dependencyOptions( } final ComponentIdentifier component = ((ResolvedDependencyResult) dependency).getSelected().getId(); - final Set directValues = directOptions.get(requestedKey(dependency.getRequested())); - final int priority = directValues != null && directValues.contains(value) ? 1 : 0; - final int previousPriority = priorities.getOrDefault(component, -1); - if (priority > previousPriority) { - options.put(component, value); - priorities.put(component, priority); - } else if (priority == previousPriority && !value.equals(options.get(component))) { - throw new GradleException( - "Conflicting javaagent options for " - + component.getDisplayName() - + " in configuration '" - + configurationName - + "'. Configure one option string per agent."); + final Map> scopes = + declarations.get(requestedKey(dependency.getRequested())); + if (scopes != null) { + scopes.forEach( + (scope, values) -> { + if (values.contains(value)) { + candidates + .computeIfAbsent(component, unused -> new HashMap<>()) + .computeIfAbsent(scope, unused -> new HashSet<>()) + .add(value); + } + }); } } + final Map options = new HashMap<>(); + candidates.forEach( + (component, scopes) -> { + final Set values = new HashSet<>(); + scopes.forEach( + (scope, declaredValues) -> { + // A descendant wins even if the consumer also directly extends the ancestor. + final boolean overridden = + scopes.keySet().stream() + .anyMatch( + other -> !other.equals(scope) && other.getHierarchy().contains(scope)); + if (!overridden) { + values.addAll(declaredValues); + } + }); + if (values.size() > 1) { + throw new GradleException( + "Conflicting javaagent options for " + + component.getDisplayName() + + " in configuration '" + + configuration.getName() + + "'. Configure one option string per agent."); + } + if (!values.isEmpty()) { + options.put(component, values.iterator().next()); + } + }); return options; } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt index b2fcef03..83e10a4b 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt @@ -7,7 +7,7 @@ import org.gradle.api.artifacts.ModuleDependency * * Import `com.ryandens.javaagent.agentOptions` in a Kotlin build script and set * `agentOptions = "12345:config.yaml"` inside a `javaagent` or `testJavaagent` dependency block. - * Declare the dependency in `runJavaagent`, `testJavaagent`, `distributionJavaagent`, or + * Declare the dependency in `applicationRunJavaagent`, `testJavaagent`, `applicationDistributionJavaagent`, or * `jibJavaagent` to scope its options to the corresponding plugin. A `javaagent` dependency is * inherited by all of these configurations; a direct declaration overrides inherited options. */ diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt index 9ea96742..b70a8d33 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -25,7 +25,7 @@ class JavaagentApplicationDistributionPlugin : private val destinationDirectory = "agent-libs" companion object { - const val CONFIGURATION_NAME = "distributionJavaagent" + const val CONFIGURATION_NAME = "applicationDistributionJavaagent" } override fun dependentProjectPlugins(): Collection = setOf("application") @@ -34,17 +34,17 @@ class JavaagentApplicationDistributionPlugin : project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { - val distributionJavaagentConfiguration = + val applicationDistributionJavaagentConfiguration = JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(distributionJavaagentConfiguration) + AgentOptionsResolver.optionsByFilePath(applicationDistributionJavaagentConfiguration) project.extensions .getByType(DistributionContainer::class.java) .named(DistributionPlugin.MAIN_DISTRIBUTION_NAME) .configure { distribution -> distribution.contents { copy -> - copy.from(distributionJavaagentConfiguration) { + copy.from(applicationDistributionJavaagentConfiguration) { it.into(destinationDirectory) } } @@ -60,10 +60,10 @@ class JavaagentApplicationDistributionPlugin : it.defaultJvmOpts = listOf("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar") .plus(it.defaultJvmOpts ?: listOf()) - it.inputs.files(distributionJavaagentConfiguration) + it.inputs.files(applicationDistributionJavaagentConfiguration) it.inputs.property("agentOptions", optionsByFilePath) // custom start script generator that replaces the placeholder - val agentFiles: Provider> = distributionJavaagentConfiguration.map { configuration -> configuration.files } + val agentFiles: Provider> = applicationDistributionJavaagentConfiguration.map { configuration -> configuration.files } it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX, optionsByFilePath) it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS, optionsByFilePath) } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt index f9bd1567..dbca398b 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt @@ -14,7 +14,7 @@ class JavaagentApplicationRunPlugin : Plugin, JavaagentPlugin { companion object { - const val CONFIGURATION_NAME = "runJavaagent" + const val CONFIGURATION_NAME = "applicationRunJavaagent" } override fun dependentProjectPlugins(): Collection = setOf("application") @@ -23,10 +23,10 @@ class JavaagentApplicationRunPlugin : project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { - val runJavaagentConfiguration = + val applicationRunJavaagentConfiguration = JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) val optionsByFilePath = - AgentOptionsResolver.optionsByFilePath(runJavaagentConfiguration) + AgentOptionsResolver.optionsByFilePath(applicationRunJavaagentConfiguration) // configure the run task to use the `javaagent` flag pointing to the dependency stored in the local Maven repository project.tasks.named(ApplicationPlugin.TASK_RUN_NAME, JavaExec::class.java).configure { // The agent jars are passed to the JVM via a CommandLineArgumentProvider (see @@ -41,11 +41,11 @@ class JavaagentApplicationRunPlugin : // the start-script task. This requires every artifact added to the javaagent configuration to // carry its producing-task dependency (builtBy); consumers that synthesize a plain File must // preserve it (see the otel example's extendedAgent wiring). - it.inputs.files(runJavaagentConfiguration) + it.inputs.files(applicationRunJavaagentConfiguration) it.inputs.property("agentOptions", optionsByFilePath) JavaForkOptionsConfigurer.configureJavaForkOptions( it, - runJavaagentConfiguration.map { configuration -> configuration.files }, + applicationRunJavaagentConfiguration.map { configuration -> configuration.files }, optionsByFilePath, ) } diff --git a/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt b/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt index 9bbec85e..f598adc1 100644 --- a/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt +++ b/plugin/src/test/kotlin/com/ryandens/javaagent/JavaagentApplicationPluginTest.kt @@ -17,16 +17,16 @@ class JavaagentApplicationPluginTest { // Verify the result assertNotNull(project.configurations.findByName("javaagent")) - assertNotNull(project.configurations.findByName("runJavaagent")) - assertNotNull(project.configurations.findByName("distributionJavaagent")) + assertNotNull(project.configurations.findByName("applicationRunJavaagent")) + assertNotNull(project.configurations.findByName("applicationDistributionJavaagent")) assertNull(project.extensions.findByName("javaagent")) } @Test fun `individual plugins create only their own agent configuration`() { val plugins = mapOf( - "com.ryandens.javaagent-application-run" to "runJavaagent", - "com.ryandens.javaagent-application-distribution" to "distributionJavaagent", + "com.ryandens.javaagent-application-run" to "applicationRunJavaagent", + "com.ryandens.javaagent-application-distribution" to "applicationDistributionJavaagent", "com.ryandens.javaagent-test" to "testJavaagent", ) for ((pluginId, configuration) in plugins) {