diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt index b338e096456..45b3f84272b 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt @@ -18,10 +18,12 @@ import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Classpath import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.property @@ -72,6 +74,51 @@ abstract class MuzzleTask @Inject constructor( @get:Optional val muzzleDirective: Property = objects.property() + /** + * Shares one resolved toolchain between JVM fingerprinting and worker execution. + * Kept internal so task graph discovery does not resolve JDKs for skipped checks or dry runs. + */ + @get:Internal + val javaLauncher: Property = objects.property().convention( + muzzleDirective.map { it.javaVersion }.flatMap { version -> + javaToolchainService.launcherFor { + languageVersion.set(JavaLanguageVersion.of(version)) + } + } + ).apply { finalizeValueOnRead() } + + /** + * Tracks the validation JVM because its platform classes are outside the classpath inputs. + * Vendor, full runtime/VM versions, OS, and architecture distinguish JVM installations. + */ + @get:Input + @get:Optional + val validationJvmIdentity = providers.provider { + val metadata = javaLauncher.orNull?.metadata + if (metadata != null) { + mapOf( + "languageVersion" to metadata.languageVersion.asInt().toString(), + "vendor" to metadata.vendor, + "runtimeVersion" to metadata.javaRuntimeVersion, + "vmVersion" to metadata.jvmVersion, + "operatingSystem" to System.getProperty("os.name"), + "architecture" to System.getProperty("os.arch"), + ) + } else if (muzzleDirective.orNull?.isCoreJdk == true) { + // coreJdk() without a version executes in the Gradle daemon. + mapOf( + "languageVersion" to System.getProperty("java.specification.version"), + "vendor" to System.getProperty("java.vendor"), + "runtimeVersion" to System.getProperty("java.runtime.version"), + "vmVersion" to System.getProperty("java.vm.version"), + "operatingSystem" to System.getProperty("os.name"), + "architecture" to System.getProperty("os.arch"), + ) + } else { + null + } + } + @get:OutputFile val result: RegularFileProperty = objects.fileProperty().convention( project.layout.buildDirectory.file("reports/$name.txt") @@ -95,29 +142,25 @@ abstract class MuzzleTask @Inject constructor( } private fun assertMuzzle(muzzleDirective: MuzzleDirective? = null) { - val workQueue = if (muzzleDirective?.javaVersion != null) { - val javaLauncher = javaToolchainService.launcherFor { - languageVersion.set(JavaLanguageVersion.of(muzzleDirective.javaVersion!!)) - }.get() + val launcher = javaLauncher.orNull + val workQueue = if (launcher != null) { // Note process isolation leaks gradle dependencies to the child process // and may need additional code on muzzle plugin to filter those out // See https://github.com/gradle/gradle/issues/33987 workerExecutor.processIsolation { forkOptions { // datadog.trace.agent.tooling.muzzle.MuzzleVersionScanPlugin needs reflective access to ClassLoader.findLoadedClass - if(javaLauncher.metadata.languageVersion > JavaLanguageVersion.of(9)) { + if(launcher.metadata.languageVersion > JavaLanguageVersion.of(9)) { jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED") } if (HostPlatform.isLinuxArm64()) { // Disable CDS to avoid SIGSEGVs on Linux arm64. jvmArgs("-Xshare:off") } - executable(javaLauncher.executablePath) + executable(launcher.executablePath) } } } else { - // noIsolation worker is OK for muzzle tasks as their checks will inspect classes outline - // and should not be impacted by the actual running JDK. workerExecutor.noIsolation() } workQueue.submit(MuzzleAction::class.java) { diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleJvmCacheInputsTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleJvmCacheInputsTest.kt new file mode 100644 index 00000000000..2f5d3d6b140 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleJvmCacheInputsTest.kt @@ -0,0 +1,186 @@ +package datadog.gradle.plugin.muzzle + +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome +import org.gradle.testkit.runner.TaskOutcome.FROM_CACHE +import org.gradle.testkit.runner.TaskOutcome.SKIPPED +import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class MuzzleJvmCacheInputsTest : MuzzlePluginTestFixture() { + @ParameterizedTest + @ValueSource(strings = ["coreJdk", "library", "fallback"]) + fun `only core JDK checks fingerprint the daemon JVM`(check: String) { + val repo = createMavenRepoFixture() + repo.publishVersions("com.example.test", "demo-lib", listOf("1.0.0")) + val directive = when (check) { + "coreJdk" -> "muzzle { pass { coreJdk() } }" + "library" -> """ + muzzle { pass { + group = "com.example.test" + module = "demo-lib" + versions = "[1.0.0,2.0.0)" + } } + """ + else -> "" + } + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + + repositories { maven { url = uri("${repo.repoUrl}") } } + + $directive + + // Simulate a changed daemon runtime without requiring several installed JDKs. + val changedField = providers.gradleProperty("changedJvmField").orNull + if (changedField != null) { + val original = System.getProperty(changedField) + System.setProperty(changedField, "different-runtime") + gradle.buildFinished { System.setProperty(changedField, original) } + } + """ + ) + val taskName = when (check) { + "coreJdk" -> "muzzle-AssertPass-core-jdk" + "library" -> "muzzle-AssertPass-com.example.test-demo-lib-1.0.0" + else -> "muzzle" + } + assertCacheTracks( + listOf("java.vendor", "java.runtime.version", "java.vm.version", "os.name", "os.arch"), + taskName, + if (check == "coreJdk") SUCCESS else UP_TO_DATE, + mapOf("MAVEN_REPOSITORY_PROXY" to repo.repoUrl), + ) + } + + @ParameterizedTest + @ValueSource(strings = ["coreJdk", "library"]) + fun `versioned check cache follows the launcher used by the isolated worker`(check: String) { + val repo = createMavenRepoFixture() + repo.publishVersions("com.example.test", "demo-lib", listOf("1.0.0")) + val taskName = if (check == "coreJdk") { + "muzzle-AssertPass-core-jdk" + } else { + "muzzle-AssertPass-com.example.test-demo-lib-1.0.0" + } + val directive = if (check == "coreJdk") { + "coreJdk(JavaVersion.current().majorVersion)" + } else { + """ + group = "com.example.test" + module = "demo-lib" + versions = "[1.0.0,2.0.0)" + javaVersion = JavaVersion.current().majorVersion + """ + } + writeProject( + """ + import datadog.gradle.plugin.muzzle.tasks.MuzzleTask + import org.gradle.jvm.toolchain.JavaInstallationMetadata + import org.gradle.jvm.toolchain.JavaLauncher + + plugins { + id("java") + id("dd-trace-java.muzzle") + } + + repositories { maven { url = uri("${repo.repoUrl}") } } + + muzzle { pass { $directive } } + + // Keep the executable and major version fixed to isolate each additional input. + class SelectedLauncher( + private val delegate: JavaLauncher, + private val changedField: String, + ) : JavaLauncher by delegate { + override fun getMetadata(): JavaInstallationMetadata = + object : JavaInstallationMetadata by delegate.metadata { + override fun getVendor() = + if (changedField == "vendor") "different-vendor" else delegate.metadata.vendor + override fun getJavaRuntimeVersion() = + if (changedField == "runtimeVersion") "different-runtime" else delegate.metadata.javaRuntimeVersion + override fun getJvmVersion() = + if (changedField == "vmVersion") "different-vm" else delegate.metadata.jvmVersion + } + } + + val changedField = providers.gradleProperty("changedJvmField").orNull + if (changedField?.startsWith("os.") == true) { + val original = System.getProperty(changedField) + System.setProperty(changedField, "different-platform") + gradle.buildFinished { System.setProperty(changedField, original) } + } + val launcher = javaToolchains.launcherFor {} + tasks.withType().configureEach { + if (name == "$taskName" && changedField != null) { + javaLauncher.set(launcher.map { SelectedLauncher(it, changedField) }) + } + } + """ + ) + assertCacheTracks( + listOf("vendor", "runtimeVersion", "vmVersion", "os.name", "os.arch"), + taskName, + env = mapOf("MAVEN_REPOSITORY_PROXY" to repo.repoUrl), + ) + } + + @ParameterizedTest + @ValueSource(strings = ["--dry-run", "-PskipMuzzle"]) + fun `skipped core JDK checks do not resolve the toolchain`(argument: String) { + writeProject( + """ + import datadog.gradle.plugin.muzzle.tasks.MuzzleTask + + plugins { + id("java") + id("dd-trace-java.muzzle") + } + + muzzle { pass { coreJdk("999") } } + + tasks.withType().configureEach { + onlyIf { !providers.gradleProperty("skipMuzzle").isPresent } + } + """ + ) + writeFile("gradle.properties", "org.gradle.java.installations.auto-download=false") + val result = run("muzzle", argument) + assertThat(result.output).contains("BUILD SUCCESSFUL") + if (argument == "-PskipMuzzle") { + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SKIPPED) + } + } + + private fun assertCacheTracks( + fields: List, + taskName: String = "muzzle-AssertPass-core-jdk", + changedOutcome: TaskOutcome = SUCCESS, + env: Map = emptyMap(), + ) { + writeFile("settings.gradle.kts", "buildCache { local { directory = file(\"task-cache\") } }", append = true) + writeNoopScanPlugin() + val task = ":dd-java-agent:instrumentation:demo:$taskName" + val first = run("muzzle", "--build-cache", env = env) + assertThat(first.task(task)?.outcome).describedAs(first.output).isEqualTo(SUCCESS) + + val unchanged = run("muzzle", "--build-cache", env = env) + assertThat(unchanged.task(task)?.outcome).describedAs(unchanged.output).isEqualTo(UP_TO_DATE) + + for (field in fields) { + val restored = run("clean", "muzzle", "--build-cache", env = env) + assertThat(restored.task(task)?.outcome).describedAs(restored.output).isEqualTo(FROM_CACHE) + + val changed = run("muzzle", "--build-cache", "-PchangedJvmField=$field", env = env) + assertThat(changed.task(task)?.outcome).describedAs(changed.output).isEqualTo(changedOutcome) + } + } +} diff --git a/docs/how_to_test.md b/docs/how_to_test.md index a0bcefdb368..4478fb02833 100644 --- a/docs/how_to_test.md +++ b/docs/how_to_test.md @@ -18,6 +18,8 @@ The project leverages different types of tests: 3. The third type of tests is **Muzzle checks**. Their goal is to check the [Muzzle directives](./how_instrumentations_work.md#muzzle), making sure instrumentations are safe to load against specific library versions. + `coreJdk(version)` and library checks with `javaVersion` fingerprint the selected JDK's major version, vendor, full runtime/VM versions, OS, and architecture; `coreJdk()` tracks the Gradle daemon JVM instead. + Changing these values invalidates cached results even within the same Java major version. 4. The fourth type of tests is **integration tests**. They test features that require a more complex environment setup.