diff --git a/README.md b/README.md index a5700528..509e89ab 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,114 @@ 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`. + +Agent options belong to dependency declarations. Each plugin provides a configuration for its own +execution or packaging context: + +| Plugin | Dependency configuration | Used by | +| --- | --- | --- | +| `com.ryandens.javaagent-application-run` | `applicationRunJavaagent` | Local application `run` | +| `com.ryandens.javaagent-test` | `testJavaagent` | Java `test` | +| `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 `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: + +```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 { + 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" + } + 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") { + agentOptions = "12345:/etc/agent/container.yaml" + } +} +``` + +Agents declared only in a plugin's configuration are included only in that context. Project +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. 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 + +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", "applicationRunJavaagent", "testJavaagent", "applicationDistributionJavaagent", "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.applicationRunJavaagent`. + ## 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..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) @@ -46,12 +48,19 @@ class JavaagentJibExtension : } val localAgentPaths = extraConfig.get().javaagentFiles.get() + val agentOptions = extraConfig.get().agentOptions.getOrElse(emptyMap()) 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 -> @@ -80,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 c8aa543e..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,38 @@ 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 + } + } + } + } + + @Test fun `can pass agent options to jib entrypoint`() { + val dependencies = """ + jibJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } + runtimeOnly 'commons-lang:commons-lang:2.6' + """ + + val result = + createAndBuildJavaagentProject( + dependencies, + listOf("jibBuildTar"), + ) + + 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 } @@ -59,6 +92,57 @@ 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 { + 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" + } + } + 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")) + assertFalse(json.contains("shared-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' @@ -89,6 +173,7 @@ class JavaagentJibExtensionFunctionalTest { private fun createAndBuildJavaagentProject( dependencies: String, buildArgs: List, + kotlinBuildScript: String? = null, ): BuildResult { val helloWorldDir = File(functionalTestDir, "hello-world") File( @@ -113,8 +198,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' 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..632b1853 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,7 @@ package com.ryandens.javaagent.jib import com.google.cloud.tools.jib.gradle.JibExtension -import com.ryandens.javaagent.JavaagentBasePlugin +import com.ryandens.javaagent.AgentOptionsResolver import org.gradle.api.Action import org.gradle.api.Plugin import org.gradle.api.Project @@ -19,7 +19,17 @@ 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 optionsByFilePath = + 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 -> + pathMap.entries.associate { (path, options) -> + java.io.File(path).name to options + } + } val destinationDirectory = project.tasks @@ -44,6 +54,7 @@ class JavaagentJibPlugin : Plugin { .toList() }, ) + extensionConfiguration.agentOptions.set(optionsByFileName) }, ) } 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..49012bcf 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("applicationRunJavaagent")) + assertNull(project.configurations.findByName("applicationDistributionJavaagent")) + 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 d0bc56ce..e118a353 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 @@ -211,6 +213,511 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X ) } + @Test fun `can pass agent options to application run task`() { + val dependencies = """ + applicationRunJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } + """ + + createJavaagentProject(dependencies) + 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 `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") + 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" + } + applicationRunJavaagent("com.ryandens.fixture:simple-agent:1.0") { + agentOptions = "12345:config.yaml" + } + applicationDistributionJavaagent("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( + """ + + 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")) + } + } + """, + ) + 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 `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( + """ + 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") + applicationRunJavaagent("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!" }) + + 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 { + applicationRunJavaagent("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. + buildScript.writeText( + buildScript.readText().substringBefore("dependencies {") + + """ + dependencies { + applicationRunJavaagent(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 `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.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('applicationRunJavaagent')).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") + 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")) } } + dependencyLocking { lockAllConfigurations() } + val agentConfigurations = setOf("javaagent", "applicationRunJavaagent", "testJavaagent", "applicationDistributionJavaagent", "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 + "--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")) + } + + @Test fun `run only agents are absent from tests and packaged distributions`() { + createJavaagentProject( + """ + applicationRunJavaagent(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')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } + javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:$otelVersion' + """ + + createJavaagentProject(dependencies) + 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 = """ + applicationDistributionJavaagent(project(':simple-agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, '12345:config.yaml') } + } + """ + + createJavaagentProject(dependencies) + 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 `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( + """ + applicationDistributionJavaagent(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( """ @@ -263,6 +770,31 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X 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']", @@ -348,7 +880,10 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X ) } - private fun runBuild(buildArgs: List): BuildResult { + private fun runBuild( + buildArgs: List, + expectFailure: Boolean = false, + ): BuildResult { // Run the build val runner = GradleRunner.create() runner.forwardOutput() @@ -369,6 +904,6 @@ DEFAULT_JVM_OPTS="\"-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar\" \"-X .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..9898d32f --- /dev/null +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptions.java @@ -0,0 +1,12 @@ +package com.ryandens.javaagent; + +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); + + private AgentOptions() {} +} 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..1d98a65e --- /dev/null +++ b/plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java @@ -0,0 +1,263 @@ +package com.ryandens.javaagent; + +import java.io.File; +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.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; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.provider.Provider; + +/** + * 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 Gradle + * task input serialization limitation with Kotlin lambdas. + */ +public final class AgentOptionsResolver { + + /** Prevent instantiation for utility class. */ + private AgentOptionsResolver() {} + + /** + * Builds a {@code filePath -> options} map from the resolved artifacts of the provided + * 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 + * execution time. + * + * @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 Provider configurationProvider) { + return configurationProvider.map( + configuration -> { + // 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); + 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; + } + 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>> 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; + } + + 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 Configuration configuration) { + final Map>> declarations = + declaredOptions(configuration); + final Map>> candidates = 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 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/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java b/plugin/src/main/java/com/ryandens/javaagent/JavaForkOptionsConfigurer.java index 5912925d..da42f841 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 optionsByFilePath options to append after {@code =}, keyed by agent file path (see + * {@link AgentOptionsResolver}) */ public static void configureJavaForkOptions( - JavaForkOptions javaForkOptions, Provider> javaagentConfiguration) { + JavaForkOptions javaForkOptions, + Provider> javaagentConfiguration, + Provider> optionsByFilePath) { final List list = new ArrayList<>(); //noinspection Convert2Lambda @@ -38,11 +43,14 @@ public static void configureJavaForkOptions( new CommandLineArgumentProvider() { @Override public Iterable asArguments() { + final Map options = optionsByFilePath.get(); return javaagentConfiguration.get().stream() .map( file -> { try { - return "-javaagent:" + file.getCanonicalPath(); + final String options0 = options.get(file.getCanonicalPath()); + 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/DependencyAgentOptions.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/DependencyAgentOptions.kt new file mode 100644 index 00000000..83e10a4b --- /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 `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. + */ +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 f7174a37..b70a8d33 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -24,18 +24,27 @@ class JavaagentApplicationDistributionPlugin : */ private val destinationDirectory = "agent-libs" + companion object { + const val CONFIGURATION_NAME = "applicationDistributionJavaagent" + } + override fun dependentProjectPlugins(): Collection = setOf("application") override fun applyAfterJavaagentSetup( project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { + val applicationDistributionJavaagentConfiguration = + JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) + val optionsByFilePath = + AgentOptionsResolver.optionsByFilePath(applicationDistributionJavaagentConfiguration) + project.extensions .getByType(DistributionContainer::class.java) .named(DistributionPlugin.MAIN_DISTRIBUTION_NAME) .configure { distribution -> distribution.contents { copy -> - copy.from(javaagentConfiguration) { + copy.from(applicationDistributionJavaagentConfiguration) { it.into(destinationDirectory) } } @@ -51,11 +60,12 @@ class JavaagentApplicationDistributionPlugin : it.defaultJvmOpts = listOf("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar") .plus(it.defaultJvmOpts ?: listOf()) - it.inputs.files(javaagentConfiguration) + it.inputs.files(applicationDistributionJavaagentConfiguration) + it.inputs.property("agentOptions", optionsByFilePath) // 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) + 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 d446e04c..dbca398b 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationRunPlugin.kt @@ -13,12 +13,20 @@ import org.gradle.api.tasks.JavaExec class JavaagentApplicationRunPlugin : Plugin, JavaagentPlugin { + companion object { + const val CONFIGURATION_NAME = "applicationRunJavaagent" + } + override fun dependentProjectPlugins(): Collection = setOf("application") override fun applyAfterJavaagentSetup( project: Project, javaagentConfiguration: NamedDomainObjectProvider, ) { + val applicationRunJavaagentConfiguration = + JavaagentBasePlugin.targetConfiguration(project, CONFIGURATION_NAME, javaagentConfiguration) + val optionsByFilePath = + 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 @@ -33,8 +41,13 @@ 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) - JavaForkOptionsConfigurer.configureJavaForkOptions(it, javaagentConfiguration.map { configuration -> configuration.files }) + it.inputs.files(applicationRunJavaagentConfiguration) + it.inputs.property("agentOptions", optionsByFilePath) + JavaForkOptionsConfigurer.configureJavaForkOptions( + it, + applicationRunJavaagentConfiguration.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 8a51619a..55fe5ecf 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 optionsByFilePath: Provider>, private val inner: ScriptGenerator = DefaultTemplateBasedStartScriptGenerator( platform.lineSeparator, @@ -25,7 +26,15 @@ class JavaagentAwareStartScriptGenerator( ) { inner.generateScript( details, - Fake(destination, javaagentConfiguration, platform.pathSeparator, platform.appHomeVar, platform.agentArgSeparator), + Fake( + destination, + javaagentConfiguration, + platform.pathSeparator, + platform.appHomeVar, + platform.agentArgSeparator, + optionsByFilePath, + platform, + ), ) } @@ -53,6 +62,8 @@ class JavaagentAwareStartScriptGenerator( private val pathSeparator: String, private val appHomeVar: String, private val agentArgSeparator: String, + private val optionsByFilePath: Provider>, + private val platform: Platform, ) : Writer() { override fun close() { inner.close() @@ -70,6 +81,27 @@ class JavaagentAwareStartScriptGenerator( inner.write(cbuf, off, len) } + /** + * Escapes a javaagent option value for safe inclusion in shell scripts. + * - 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("\"", "\"\\\"\"") + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + .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 @@ -97,11 +129,15 @@ class JavaagentAwareStartScriptGenerator( .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar ", "") .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", "") } else { + val options = optionsByFilePath.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.canonicalPath]?.let { "=" + escapeOptionValue(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..5705a83d 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,6 +11,18 @@ import org.gradle.api.Project class JavaagentBasePlugin : Plugin { companion object { const val CONFIGURATION_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()) + } } override fun apply(project: Project) { diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt index 955859b2..305ab64b 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentTestPlugin.kt @@ -46,14 +46,19 @@ class JavaagentTestPlugin : val enabled = calculateEnabled(project, extension) + val optionsByFilePath = + 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 { if (enabled.get()) { + it.inputs.property("agentOptions", optionsByFilePath) JavaForkOptionsConfigurer.configureJavaForkOptions( it, javaagentTestConfiguration.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 129a2f78..f598adc1 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("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 "applicationRunJavaagent", + "com.ryandens.javaagent-application-distribution" to "applicationDistributionJavaagent", + "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")) + } } } 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); + } } }