diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index d3164f0e..90d5e3a0 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -15,7 +15,10 @@ jobs: - uses: gradle/actions/wrapper-validation@v6 build: name: "Javaagent Gradle Plugin Validation" - runs-on: ubuntu-latest + strategy: + matrix: + platform: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v7.0.0 - uses: actions/setup-java@v5.4.0 @@ -29,4 +32,4 @@ jobs: cache-read-only: false - name: Execute Gradle build - run: ./gradlew build + run: ${{ matrix.platform == 'windows-latest' && '.\gradlew.bat' || './gradlew' }} build diff --git a/example-projects/buildSrc/build.gradle.kts b/example-projects/buildSrc/build.gradle.kts index d9436064..3998a9f4 100644 --- a/example-projects/buildSrc/build.gradle.kts +++ b/example-projects/buildSrc/build.gradle.kts @@ -12,6 +12,6 @@ dependencies { implementation("com.ryandens:plugin:0.11.0") */ implementation("com.gradleup.shadow:com.gradleup.shadow.gradle.plugin:9.4.3") - implementation("io.opentelemetry.instrumentation.muzzle-generation:io.opentelemetry.instrumentation.muzzle-generation.gradle.plugin:2.27.0-alpha") - implementation("io.opentelemetry.instrumentation.muzzle-check:io.opentelemetry.instrumentation.muzzle-check.gradle.plugin:2.27.0-alpha") + implementation("io.opentelemetry.instrumentation.muzzle-generation:io.opentelemetry.instrumentation.muzzle-generation.gradle.plugin:2.29.0-alpha") + implementation("io.opentelemetry.instrumentation.muzzle-check:io.opentelemetry.instrumentation.muzzle-check.gradle.plugin:2.29.0-alpha") } diff --git a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt index dd5d4b59..c8aa543e 100644 --- a/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt +++ b/jib/src/functionalTest/kotlin/JavaagentJibExtensionFunctionalTest.kt @@ -91,14 +91,16 @@ class JavaagentJibExtensionFunctionalTest { buildArgs: List, ): BuildResult { val helloWorldDir = File(functionalTestDir, "hello-world") - File("src/functionalTest/resources/hello-world-project/").copyRecursively(helloWorldDir) + File( + "src${File.separator}functionalTest${File.separator}resources${File.separator}hello-world-project", + ).copyRecursively(helloWorldDir) val simpleAgentTestDir = File(functionalTestDir, "simple-agent") val simpleAgentBuildScript = simpleAgentTestDir.resolve("build.gradle.kts") - File("../simple-agent/").copyRecursively(simpleAgentTestDir) + File("..${File.separator}simple-agent").copyRecursively(simpleAgentTestDir) // remove a constant from the above file simpleAgentBuildScript.writeText( simpleAgentBuildScript.readText().replace( - "id(\"com.ryandens.java-conventions\")\n", + "id(\"com.ryandens.java-conventions\")", "", ), ) diff --git a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt index 45dcb451..a7640dc7 100644 --- a/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/ryandens/javaagent/JavaagentPluginFunctionalTest.kt @@ -1,10 +1,12 @@ package com.ryandens.javaagent import org.gradle.internal.jvm.Jvm +import org.gradle.internal.os.OperatingSystem import org.gradle.testkit.runner.BuildResult import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import java.io.File +import java.nio.file.Paths import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -23,9 +25,13 @@ class JavaagentPluginFunctionalTest { private lateinit var helloWorldDir: File + private val isWindows = OperatingSystem.current().isWindows + + private val scriptExtension = if (isWindows) ".bat" else "" + @BeforeTest fun beforeEach() { - functionalTestDir = File("build/functionalTest") + functionalTestDir = File("build", "functionalTest") functionalTestDir.mkdirs() helloWorldDir = File(functionalTestDir, "hello-world") } @@ -69,7 +75,8 @@ class JavaagentPluginFunctionalTest { } @Test fun `can attach to test task`() { - val otelVersion = "1.11.1" + // A real third-party javaagent used to verify the plugin attaches it and it logs its version banner. + val otelVersion = "2.29.0" val dependencies = """ javaagent project(':simple-agent') testJavaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:$otelVersion' @@ -92,7 +99,8 @@ class JavaagentPluginFunctionalTest { } @Test fun `can attach two agents to application run task`() { - val otelVersion = "1.11.1" + // A real third-party javaagent used to verify the plugin attaches it and it logs its version banner. + val otelVersion = "2.29.0" val dependencies = """ javaagent project(':simple-agent') javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:$otelVersion' @@ -120,27 +128,30 @@ class JavaagentPluginFunctionalTest { val result = runBuild(listOf("--configuration-cache", "build", "installDist", "execStartScript")) // verify the distribution was created properly - val applicationDistribution = File(functionalTestDir, "hello-world/build/distributions/hello-world.tar") + val applicationDistribution = + File(functionalTestDir, "hello-world${File.separator}build${File.separator}distributions${File.separator}hello-world.tar") assertTrue(applicationDistribution.exists()) // verify the expected text was injected into the start script - val expectedDefaultJavaOpts = """ + 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" "-Xmx256m"""" + assertTrue(applicationDistributionScript.readText().contains(expectedWindowsDefaultJvmOpts)) + } else { + val expectedDefaultJavaOpts = """ DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/agent-libs/simple-agent.jar -Xmx256m" """ - val applicationDistributionScript = File(functionalTestDir, "hello-world/build/scripts/hello-world") - assertTrue(applicationDistributionScript.readText().contains(expectedDefaultJavaOpts)) - - /* - * TODO add support for windows - * val expectedWindowsDefaultJvmOpts = """ -DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/lib/simple-agent.jar -Xmx256m" -""" - val applicationDistributionScript = File(functionalTestDir, "hello-world/build/scripts/hello-world.bat") - assertTrue(applicationDistributionScript.readText().contains(expectedWindowsDefaultJvmOpts)) - */ + assertTrue(applicationDistributionScript.readText().contains(expectedDefaultJavaOpts)) + } // verify the agent was added to the /lib/ dir of the distribution - assertTrue(File(functionalTestDir, "hello-world/build/install/hello-world/agent-libs/simple-agent.jar").exists()) + assertTrue( + File( + functionalTestDir, + "hello-world/build/install/hello-world/agent-libs/simple-agent.jar".replace("/", File.separator), + ).exists(), + ) // Verify the result assertTrue(result.output.contains("Hello World!")) @@ -154,23 +165,23 @@ DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/lib/simple-agent.jar -Xmx256m" @Test fun `can handle upgrade of agent with build cache`() { createJavaagentProject( """ - javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:1.30.0' + javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:2.28.0' """.trimIndent(), ) val firstBuild = runBuild(listOf("--build-cache", "build", "installDist", "execStartScript")) assertTrue( - firstBuild.output.contains("io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.30.0"), + firstBuild.output.contains("io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 2.28.0"), ) val buildScript = helloWorldDir.resolve("build.gradle") // replace the agent version with a newer version - buildScript.writeText(buildScript.readText().replace("1.30.0", "1.31.0")) + buildScript.writeText(buildScript.readText().replace("2.28.0", "2.29.0")) val secondBuild = runBuild(listOf("--build-cache", "build", "installDist", "execStartScript")) assertTrue( - secondBuild.output.contains("io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.31.0"), + secondBuild.output.contains("io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 2.29.0"), ) } @@ -180,24 +191,36 @@ DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/lib/simple-agent.jar -Xmx256m" val result = runBuild(listOf("build", "installDist", "execStartScript")) // verify the distribution was created properly - val applicationDistribution = File(functionalTestDir, "hello-world/build/distributions/hello-world.tar") + val applicationDistribution = + File(functionalTestDir, "hello-world${File.separator}build${File.separator}distributions${File.separator}hello-world.tar") assertTrue(applicationDistribution.exists()) - val applicationDistributionScript = File(functionalTestDir, "hello-world/build/scripts/hello-world") - assertTrue(applicationDistributionScript.readText().contains("""DEFAULT_JVM_OPTS="-Xmx256m""")) - - assertFalse(File(functionalTestDir, "hello-world/build/install/hello-world/agent-libs/").exists()) + val applicationDistributionScript = + File(functionalTestDir, "hello-world${File.separator}build${File.separator}scripts${File.separator}hello-world$scriptExtension") + if (isWindows) { + assertTrue(applicationDistributionScript.readText().contains("""set DEFAULT_JVM_OPTS="-Xmx256m"""")) + } else { + assertTrue(applicationDistributionScript.readText().contains("""DEFAULT_JVM_OPTS="-Xmx256m""")) + } + + assertFalse( + File( + functionalTestDir, + "hello-world${File.separator}build${File.separator}install${File.separator}hello-world${File.separator}agent-libs/", + ).exists(), + ) assertTrue(result.output.contains("Hello World!")) } private fun createJavaagentProject(dependencies: String) { - File("src/functionalTest/resources/hello-world-project/").copyRecursively(helloWorldDir) + val helloWorldDir = File(functionalTestDir, "hello-world") + Paths.get("src", "functionalTest", "resources", "hello-world-project").toFile().copyRecursively(helloWorldDir) val simpleAgentTestDir = File(functionalTestDir, "simple-agent") val simpleAgentBuildScript = simpleAgentTestDir.resolve("build.gradle.kts") - File("../simple-agent/").copyRecursively(simpleAgentTestDir) + Paths.get("..", "simple-agent").toFile().copyRecursively(simpleAgentTestDir) simpleAgentBuildScript.writeText( simpleAgentBuildScript.readText().replace( - "id(\"com.ryandens.java-conventions\")\n", + "id(\"com.ryandens.java-conventions\")", "", ), ) @@ -210,6 +233,23 @@ DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/lib/simple-agent.jar -Xmx256m" """, ) + // On Windows a `.bat` file is not a directly executable image, so it cannot be launched via + // ProcessBuilder/CreateProcess (which is what Gradle's Exec task uses). It must be run through the + // command interpreter instead, e.g. `cmd /c hello-world.bat`. + val execCommandLine = + if (isWindows) { + "'cmd', '/c', 'hello-world.bat'" + } else { + "'./hello-world'" + } + // Escape backslashes and quotes so a Windows JAVA_HOME embeds cleanly in the generated Groovy string literal. + val javaHome = + Jvm + .current() + .getJavaHome() + .toString() + .replace("\\", "\\\\") + .replace("\"", "\\\"") helloWorldDir.resolve("build.gradle").writeText( """ plugins { @@ -232,12 +272,11 @@ DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/lib/simple-agent.jar -Xmx256m" } task execStartScript(type: Exec) { - inputs.files(fileTree('${helloWorldDir.canonicalPath}/build/install/') { - builtBy tasks.named('installDist') - }) - workingDir '${helloWorldDir.canonicalPath}/build/install/hello-world/bin/' - commandLine './hello-world' - environment JAVA_HOME: "${Jvm.current().getJavaHome()}" + dependsOn('installDist') + inputs.files(layout.buildDirectory.dir('install')) + workingDir(layout.buildDirectory.dir('install').map { it.dir('hello-world').dir('bin') }) + commandLine $execCommandLine + environment JAVA_HOME: "$javaHome" } test { @@ -262,6 +301,20 @@ DEFAULT_JVM_OPTS="-javaagent:${"$"}APP_HOME/lib/simple-agent.jar -Xmx256m" runner.withPluginClasspath() runner.withArguments(buildArgs) runner.withProjectDir(functionalTestDir) + if (isWindows) { + // Keep the TestKit Gradle home short. Its default lives under the deeply nested + // build/tmp/functionalTest/work/.gradle-test-kit, which pushes cached agent jars -- e.g. the + // OpenTelemetry javaagent at caches/modules-2/files-2.1/...//opentelemetry-javaagent-.jar + // -- past Windows' 260-character MAX_PATH. The JVM's native appendToBootstrapClassLoaderSearch + // then rejects the path with IllegalArgumentException and the agent fails to install. Rooting the + // TestKit home at the drive root keeps the full agent jar path comfortably within the limit. + val driveRoot = + functionalTestDir.absoluteFile + .toPath() + .root + .toFile() + runner.withTestKitDir(File(driveRoot, "gtk")) + } return runner.build() } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt index 3645e5fa..f7174a37 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentApplicationDistributionPlugin.kt @@ -6,9 +6,10 @@ import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.distribution.DistributionContainer import org.gradle.api.distribution.plugins.DistributionPlugin -import org.gradle.api.internal.plugins.WindowsStartScriptGenerator import org.gradle.api.plugins.ApplicationPlugin +import org.gradle.api.provider.Provider import org.gradle.api.tasks.application.CreateStartScripts +import java.io.File /** * Gradle plugin for configuration of the [DistributionPlugin.MAIN_DISTRIBUTION_NAME] after it has been configured by @@ -52,14 +53,9 @@ class JavaagentApplicationDistributionPlugin : .plus(it.defaultJvmOpts ?: listOf()) it.inputs.files(javaagentConfiguration) // custom start script generator that replaces the placeholder - it.unixStartScriptGenerator = - JavaagentAwareStartScriptGenerator( - javaagentConfiguration.map { configuration -> - configuration.files - }, - ) - // TODO build support for windows - it.windowsStartScriptGenerator = WindowsStartScriptGenerator() + val agentFiles: Provider> = javaagentConfiguration.map { configuration -> configuration.files } + it.unixStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.UNIX) + it.windowsStartScriptGenerator = JavaagentAwareStartScriptGenerator(agentFiles, Platform.WINDOWS) } } } diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt index 247a0fa2..5f517d7c 100644 --- a/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/JavaagentAwareStartScriptGenerator.kt @@ -3,7 +3,6 @@ package com.ryandens.javaagent import org.gradle.api.Transformer import org.gradle.api.internal.plugins.DefaultTemplateBasedStartScriptGenerator import org.gradle.api.internal.plugins.StartScriptTemplateBindingFactory -import org.gradle.api.internal.plugins.UnixStartScriptGenerator import org.gradle.api.provider.Provider import org.gradle.jvm.application.scripts.JavaAppStartScriptGenerationDetails import org.gradle.jvm.application.scripts.ScriptGenerator @@ -12,22 +11,27 @@ import java.io.Writer class JavaagentAwareStartScriptGenerator( private val javaagentConfiguration: Provider>, + private val platform: Platform, private val inner: ScriptGenerator = DefaultTemplateBasedStartScriptGenerator( - "\n", - FakeTransformer(StartScriptTemplateBindingFactory.unix()), - UnixStartScriptGenerator().template, + platform.lineSeparator, + FakeTransformer(platform.templateBindingFactory, platform.defaultJvmOptsMapper), + platform.template, ), ) : ScriptGenerator { override fun generateScript( details: JavaAppStartScriptGenerationDetails, destination: Writer, ) { - inner.generateScript(details, Fake(destination, javaagentConfiguration)) + inner.generateScript( + details, + Fake(destination, javaagentConfiguration, platform.pathSeparator, platform.appHomeVar, platform.agentArgSeparator), + ) } private class FakeTransformer( private val inner: StartScriptTemplateBindingFactory, + private val defaultJvmOptsMapper: (String) -> String, ) : Transformer, JavaAppStartScriptGenerationDetails> by inner { override fun transform(`in`: JavaAppStartScriptGenerationDetails): MutableMap { val result = inner.transform(`in`) @@ -38,7 +42,7 @@ class JavaagentAwareStartScriptGenerator( } else { jvmOpts } - result["defaultJvmOpts"] = trimmedJvmOpts.replace("\" \"", " ") + result["defaultJvmOpts"] = defaultJvmOptsMapper(trimmedJvmOpts) return result } } @@ -46,6 +50,9 @@ class JavaagentAwareStartScriptGenerator( private class Fake( private val inner: Writer, private val javaagentFiles: Provider>, + private val pathSeparator: String, + private val appHomeVar: String, + private val agentArgSeparator: String, ) : Writer() { override fun close() { inner.close() @@ -63,20 +70,34 @@ class JavaagentAwareStartScriptGenerator( inner.write(cbuf, off, len) } + /** + * 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 + * `-javaagent:` option per agent, each pointing at the distribution's `agent-libs` directory (joined with + * the platform's [agentArgSeparator]). When there are no agents, the placeholder together with its + * surrounding quoting and trailing space is stripped out. Text that does not contain the placeholder is + * written through unchanged. + */ override fun write(str: String) { val files = javaagentFiles.get() val replace = if (files.isEmpty()) { - // handles case gracefully where there is a trailing space that needs to be removed if ogther default jvm opts are supplied + // Remove the placeholder opt when there are no agents. On Windows each opt is its own quoted token + // (`"-javaagent:...PLACEHOLDER.jar" "-Xmx256m"`), so the surrounding quotes and trailing space must + // be stripped too; on Unix the opts are collapsed into a single quoted string + // (`"-javaagent:...PLACEHOLDER.jar -Xmx256m"`), so only the unquoted placeholder and its trailing + // space are removed. The trailing-space variants run first to avoid leaving a dangling separator. str - .replace( - "-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar ", - "", - ).replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", "") + .replace("\"-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar\" ", "") + .replace("\"-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar\"", "") + .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar ", "") + .replace("-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", "") } else { str.replace( "-javaagent:COM_RYANDENS_JAVAAGENTS_PLACEHOLDER.jar", - javaagentFiles.get().joinToString(" ") { jar -> "-javaagent:\$APP_HOME/agent-libs/${jar.name}" }, + files.joinToString( + agentArgSeparator, + ) { jar -> "-javaagent:$appHomeVar${pathSeparator}agent-libs$pathSeparator${jar.name}" }, ) } super.write(replace) diff --git a/plugin/src/main/kotlin/com/ryandens/javaagent/Platform.kt b/plugin/src/main/kotlin/com/ryandens/javaagent/Platform.kt new file mode 100644 index 00000000..7d69dac0 --- /dev/null +++ b/plugin/src/main/kotlin/com/ryandens/javaagent/Platform.kt @@ -0,0 +1,46 @@ +package com.ryandens.javaagent + +import org.gradle.api.internal.plugins.StartScriptTemplateBindingFactory +import org.gradle.api.internal.plugins.UnixStartScriptGenerator +import org.gradle.api.internal.plugins.WindowsStartScriptGenerator +import org.gradle.api.resources.TextResource + +enum class Platform( + val lineSeparator: String, + val pathSeparator: String, + val appHomeVar: String, + val templateBindingFactory: StartScriptTemplateBindingFactory, + val template: TextResource, + /** + * Separator placed between multiple `-javaagent` options. On Windows each option must remain its own + * quoted token (`" "` = close quote, space, open quote); on Unix the options share a single quoted string, + * so a plain space suffices. + */ + val agentArgSeparator: String, + /** + * Normalizes the template's rendered `defaultJvmOpts`. On Unix the individually quoted options are + * collapsed into a single space-separated string that the shell word-splits back apart when the unquoted + * `$DEFAULT_JVM_OPTS` is expanded; on Windows the batch script does not word-split inside quotes, so each + * option must stay its own quoted token and the value is left unchanged. + */ + val defaultJvmOptsMapper: (String) -> String, +) { + UNIX( + "\n", + "/", + "\$APP_HOME", + StartScriptTemplateBindingFactory.unix(), + UnixStartScriptGenerator().template, + " ", + { it.replace("\" \"", " ") }, + ), + WINDOWS( + "\r\n", + "\\", + "%APP_HOME%", + StartScriptTemplateBindingFactory.windows(), + WindowsStartScriptGenerator().template, + "\" \"", + { it }, + ), +}