Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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`.

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
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 @@ -46,12 +46,19 @@ class JavaagentJibExtension :
}

val localAgentPaths = extraConfig.get().javaagentFiles.get()
val agentOptions = extraConfig.get().agentOptions.get()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High jib/JavaagentJibExtension.kt:49

extendContainerBuildPlan() throws IllegalStateException whenever agentOptions is unset. For Jib integrations like tel.schich.tinyjib where agentOptions is never configured, this breaks previously working builds even when no agent options are needed. The call to extraConfig.get().agentOptions.get() on line 49 unconditionally finalizes the MapProperty, but no default is provided, so any consumer that doesn't explicitly set agentOptions fails. Consider calling .getOrElse(emptyMap()) instead of .get() so unset options default to an empty map.

Suggested change
val agentOptions = extraConfig.get().agentOptions.get()
val agentOptions = extraConfig.get().agentOptions.getOrElse(emptyMap())
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @jib-common/src/main/kotlin/com/ryandens/javaagent/jib/JavaagentJibExtension.kt around line 49:

`extendContainerBuildPlan()` throws `IllegalStateException` whenever `agentOptions` is unset. For Jib integrations like `tel.schich.tinyjib` where `agentOptions` is never configured, this breaks previously working builds even when no agent options are needed. The call to `extraConfig.get().agentOptions.get()` on line 49 unconditionally finalizes the `MapProperty`, but no default is provided, so any consumer that doesn't explicitly set `agentOptions` fails. Consider calling `.getOrElse(emptyMap())` instead of `.get()` so unset options default to an empty map.


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
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 `works even without any javaagent dependencies`() {
val dependencies = """
runtimeOnly 'commons-lang:commons-lang:2.6'
Expand Down Expand Up @@ -89,6 +124,7 @@ class JavaagentJibExtensionFunctionalTest {
private fun createAndBuildJavaagentProject(
dependencies: String,
buildArgs: List<String>,
extraBuildScript: String = "",
): BuildResult {
val helloWorldDir = File(functionalTestDir, "hello-world")
File(
Expand Down Expand Up @@ -150,6 +186,8 @@ class JavaagentJibExtensionFunctionalTest {
image = "javaagent-hello-world"
}
}

$extraBuildScript
""",
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,6 +23,10 @@ class JavaagentJibPlugin : Plugin<Project> {

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(
Expand All @@ -44,6 +50,7 @@ class JavaagentJibPlugin : Plugin<Project> {
.toList()
},
)
extensionConfiguration.agentOptions.set(optionsByFileName)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `=<options>` 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(
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
""",
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()}).
*
* <p>This class is written in Java for the same reason as {@link JavaForkOptionsConfigurer}: to
* avoid the <a
* href="https://docs.gradle.org/7.5.1/userguide/validation_problems.html#implementation_unknown">Gradle
* task input serialization limitation</a> 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.
*
* <p>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<Map<String, String>> optionsByFileName(
final Configuration configuration, final Provider<Map<String, String>> coordinateOptions) {
return configuration
.getIncoming()
.getArtifacts()
.getResolvedArtifacts()
.map(
artifacts -> {
final Map<String, String> coordinates = coordinateOptions.getOrElse(new HashMap<>());
final Map<String, String> 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium javaagent/AgentOptionsResolver.java:60

optionsByFileName keys the options map by artifact.getFile().getName(), so two agents whose resolved jars share the same basename (e.g. com.acme:agent:1.0 and org.other:agent:1.0, both agent-1.0.jar) collide. The second put overwrites the first, causing one agent's options to be lost and both -javaagent: arguments to receive whichever options survived. Using the file name as the join key cannot distinguish the two artifacts, so consider keying by a more stable identifier such as the coordinate or the full file path.

Also found in 1 other location(s)

plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt:111

JavaagentAwareStartScriptGenerator.Fake.write looks up options with options[jar.name], but the resolver map is keyed only by the jar file name. If two configured agents resolve to different artifacts that share the same basename (for example two project agents both published as agent.jar), one entry overwrites the other and the generated start script applies the wrong =&lt;options&gt; suffix or drops one agent's options entirely.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @plugin/src/main/java/com/ryandens/javaagent/AgentOptionsResolver.java around line 60:

`optionsByFileName` keys the options map by `artifact.getFile().getName()`, so two agents whose resolved jars share the same basename (e.g. `com.acme:agent:1.0` and `org.other:agent:1.0`, both `agent-1.0.jar`) collide. The second `put` overwrites the first, causing one agent's options to be lost and both `-javaagent:` arguments to receive whichever options survived. Using the file name as the join key cannot distinguish the two artifacts, so consider keying by a more stable identifier such as the coordinate or the full file path.

Also found in 1 other location(s):
- plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt:111 -- `JavaagentAwareStartScriptGenerator.Fake.write` looks up options with `options[jar.name]`, but the resolver map is keyed only by the jar file name. If two configured agents resolve to different artifacts that share the same basename (for example two project agents both published as `agent.jar`), one entry overwrites the other and the generated start script applies the wrong `=<options>` suffix or drops one agent's options entirely.

}
}
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();
}
}
Loading
Loading