Skip to content
Merged
108 changes: 108 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<jar>=<options>` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -18,6 +19,8 @@ public class JibExtensionConfiguration {

private final ListProperty<File> javaagentFiles;

private final MapProperty<String, String> agentOptions;

/**
* Instantiated by Jib's plugin extension mechanism
*
Expand All @@ -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);
}

/**
Expand All @@ -52,4 +56,14 @@ public JibExtensionConfiguration(final ObjectFactory objectFactory) {
ListProperty<File> 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 =<options>}.
* Package-private: intended to be accessed only by {@link JavaagentJibExtension}.
*/
@Input
MapProperty<String, String> getAgentOptions() {
return agentOptions;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Class<JibExtensionConfiguration>> = Optional.of(JibExtensionConfiguration::class.java)
Expand All @@ -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<String> {
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 ->
Expand Down Expand Up @@ -80,10 +89,12 @@ class JavaagentJibExtension :
project: Project,
javaagentConfiguration: NamedDomainObjectProvider<Configuration>,
) {
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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
"""

Expand All @@ -51,14 +53,96 @@ 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<ArchiveInputStream<TarArchiveEntry>>(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 `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<ArchiveInputStream<TarArchiveEntry>>(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'
Expand Down Expand Up @@ -89,6 +173,7 @@ class JavaagentJibExtensionFunctionalTest {
private fun createAndBuildJavaagentProject(
dependencies: String,
buildArgs: List<String>,
kotlinBuildScript: String? = null,
): BuildResult {
val helloWorldDir = File(functionalTestDir, "hello-world")
File(
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,7 +19,17 @@ class JavaagentJibPlugin : Plugin<Project> {
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
Expand All @@ -44,6 +54,7 @@ class JavaagentJibPlugin : Plugin<Project> {
.toList()
},
)
extensionConfiguration.agentOptions.set(optionsByFileName)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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" })
Expand Down
Loading
Loading