diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index ad06adecc27..75414ce780f 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -106,10 +106,11 @@ testing { val test by getting(JvmTestSuite::class) { dependencies { implementation(libs.assertj.core) + runtimeOnly(libs.junit.platform.launcher) } targets.configureEach { testTask.configure { - enabled = providers.systemProperty("runBuildSrcTests").isPresent or providers.systemProperty("idea.active").isPresent + enabled = providers.gradleProperty("runBuildSrcTests").isPresent or providers.systemProperty("idea.active").isPresent } } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt index 4616363329b..0535803d00d 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt @@ -23,6 +23,7 @@ open class MuzzleDirective : Serializable { var additionalDependencies: MutableList = ArrayList() internal var additionalRepositories: MutableList> = ArrayList() internal var excludedDependencies: MutableList = ArrayList() + internal var versionSubstitutions: MutableList = ArrayList() var assertPass: Boolean = false var assertInverse: Boolean = false var skipFromReport: Boolean = false @@ -64,6 +65,16 @@ open class MuzzleDirective : Serializable { excludedDependencies.add(excludeString) } + /** + * Replaces an exact dependency coordinate during muzzle Gradle resolution. + * + * Both parameters must be in `group:module:version` form. + * May be called multiple times to register multiple substitutions. + */ + fun substituteVersion(requested: String, target: String) { + versionSubstitutions.add(VersionSubstitution.parse(requested, target)) + } + /** * Get the list of repositories to use for this muzzle directive. * diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt index 876c2234218..7a76e361bc6 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt @@ -185,6 +185,8 @@ class MuzzlePlugin : Plugin { } } instrumentationProject.configurations.register(muzzleTaskName) { + MuzzleVersionSubstitutionSupport.applyTo(instrumentationProject, this, muzzleDirective) + if (!muzzleDirective.isCoreJdk && versionArtifact != null) { val depId = buildString { append("${versionArtifact.groupId}:${versionArtifact.artifactId}:${versionArtifact.version}") diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionSubstitutionSupport.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionSubstitutionSupport.kt new file mode 100644 index 00000000000..fbb04d80c1f --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionSubstitutionSupport.kt @@ -0,0 +1,97 @@ +package datadog.gradle.plugin.muzzle + +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.readText +import kotlin.io.path.writeText + +internal object MuzzleVersionSubstitutionSupport { + fun applyTo(project: Project, configuration: Configuration, directive: MuzzleDirective) { + val substitutions = directive.versionSubstitutions + if (substitutions.isEmpty()) return + + val repoDir = materializeSubstitutionRepository(project, configuration, substitutions) + project.repositories.maven { + name = "${configuration.name}MuzzleSubstitutions" + url = repoDir.toUri() + } + + configuration.resolutionStrategy.eachDependency { + val match = substitutions.firstOrNull { it.matches(requested.group, requested.name, requested.version) } + if (match != null) { + useTarget(match.targetNotation) + because("Muzzle substituteVersion override for ${match.requestedNotation}") + } + } + } + + private fun materializeSubstitutionRepository( + project: Project, + configuration: Configuration, + substitutions: List + ): Path { + val repoDir = project.layout.buildDirectory + .dir("generated/muzzle-version-substitutions/${configuration.name}") + .get() + .asFile + .toPath() + Files.createDirectories(repoDir) + substitutions.forEach { materializeSubstitution(project, repoDir, it) } + return repoDir + } + + private fun materializeSubstitution(project: Project, repoDir: Path, substitution: VersionSubstitution) { + val targetPom = resolveArtifactFile(project, "${substitution.targetNotation}@pom") + val destinationDir = repoDir + .resolve(substitution.requestedGroup.replace('.', '/')) + .resolve(substitution.requestedModule) + .resolve(substitution.requestedVersion) + Files.createDirectories(destinationDir) + val destinationPom = destinationDir.resolve("${substitution.requestedModule}-${substitution.requestedVersion}.pom") + Files.copy( + targetPom, + destinationPom, + StandardCopyOption.REPLACE_EXISTING + ) + rewriteProjectVersion(destinationPom, substitution) + + resolveOptionalArtifactFile(project, substitution.targetNotation)?.let { artifactFile -> + if (Files.isSameFile(targetPom, artifactFile)) { + return@let + } + val ext = artifactFile.fileName.toString().substringAfterLast('.', "") + val artifactName = "${substitution.requestedModule}-${substitution.requestedVersion}.${ext}" + Files.copy(artifactFile, destinationDir.resolve(artifactName), StandardCopyOption.REPLACE_EXISTING) + } + } + + private fun resolveArtifactFile(project: Project, notation: String): Path = + project.configurations.detachedConfiguration(project.dependencies.create(notation)).apply { + isTransitive = false + }.singleFile.toPath() + + private fun resolveOptionalArtifactFile(project: Project, notation: String): Path? = + project.configurations.detachedConfiguration(project.dependencies.create(notation)).apply { + isTransitive = false + }.resolvedConfiguration.lenientConfiguration + .artifacts + .singleOrNull() + ?.file + ?.toPath() + + private fun rewriteProjectVersion(pomFile: Path, substitution: VersionSubstitution) { + val artifactIdLine = "${substitution.targetModule}" + val versionLine = "${substitution.targetVersion}" + val replacementVersionLine = "${substitution.requestedVersion}" + val pattern = Regex("${Regex.escape(artifactIdLine)}\\s*${Regex.escape(versionLine)}") + val content = pomFile.readText() + val rewritten = content.replaceFirst(pattern, "$artifactIdLine\n $replacementVersionLine") + check(rewritten != content) { + "Could not rewrite version '${substitution.targetVersion}' to '${substitution.requestedVersion}' in POM for ${substitution.targetNotation}" + } + pomFile.writeText(rewritten) + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSubstitution.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSubstitution.kt new file mode 100644 index 00000000000..577eae05fce --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSubstitution.kt @@ -0,0 +1,44 @@ +package datadog.gradle.plugin.muzzle + +import java.io.Serializable + +internal data class VersionSubstitution( + val requestedGroup: String, + val requestedModule: String, + val requestedVersion: String, + val targetGroup: String, + val targetModule: String, + val targetVersion: String, +) : Serializable { + val requestedNotation: String + get() = "$requestedGroup:$requestedModule:$requestedVersion" + + val targetNotation: String + get() = "$targetGroup:$targetModule:$targetVersion" + + fun matches(group: String, module: String, version: String?): Boolean = + requestedGroup == group && requestedModule == module && requestedVersion == version + + companion object { + fun parse(requested: String, target: String): VersionSubstitution { + val requestedParts = parseCoordinate(requested) + val targetParts = parseCoordinate(target) + return VersionSubstitution( + requestedGroup = requestedParts[0], + requestedModule = requestedParts[1], + requestedVersion = requestedParts[2], + targetGroup = targetParts[0], + targetModule = targetParts[1], + targetVersion = targetParts[2], + ) + } + + private fun parseCoordinate(coordinate: String): List { + val parts = coordinate.split(":") + require(parts.size == 3 && parts.none { it.isBlank() }) { + "Expected dependency coordinate in 'group:module:version' form but got '$coordinate'" + } + return parts + } + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionSubstitutionSupportTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionSubstitutionSupportTest.kt new file mode 100644 index 00000000000..3bb27f38a6d --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionSubstitutionSupportTest.kt @@ -0,0 +1,153 @@ +package datadog.gradle.plugin.muzzle + +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.util.zip.ZipOutputStream +import kotlin.io.path.createDirectories +import kotlin.io.path.outputStream +import kotlin.io.path.readText +import kotlin.io.path.writeText + +class MuzzleVersionSubstitutionSupportTest { + + @TempDir + lateinit var tempDir: Path + + @Test + fun `substituteVersion rewrites missing module version during resolution`() { + val project = ProjectBuilder.builder() + .withProjectDir(tempDir.resolve("project").toFile()) + .withName("test-project") + .build() + + val repoDir = tempDir.resolve("repo") + writeModule(repoDir, "org.example", "demo", "1.0") + + project.repositories.maven { + url = repoDir.toUri() + } + + val directive = MuzzleDirective().apply { + substituteVersion("org.example:demo:2.0", "org.example:demo:1.0") + } + + val configuration = project.configurations.create("muzzleTest") { + isCanBeResolved = true + isCanBeConsumed = false + MuzzleVersionSubstitutionSupport.applyTo(project, this, directive) + } + project.dependencies.add(configuration.name, "org.example:demo:2.0") + + val resolved = configuration.resolvedConfiguration.resolvedArtifacts.single() + assertThat(resolved.moduleVersion.id.group).isEqualTo("org.example") + assertThat(resolved.name).isEqualTo("demo") + assertThat(resolved.moduleVersion.id.version).isEqualTo("1.0") + } + + @Test + fun `substituteVersion supports multiple substitutions per directive`() { + val project = ProjectBuilder.builder() + .withProjectDir(tempDir.resolve("project").toFile()) + .withName("test-project") + .build() + + val repoDir = tempDir.resolve("repo") + writeModule(repoDir, "org.example", "alpha", "1.0") + writeModule(repoDir, "org.example", "beta", "1.0") + + project.repositories.maven { + url = repoDir.toUri() + } + + val directive = MuzzleDirective().apply { + substituteVersion("org.example:alpha:2.0", "org.example:alpha:1.0") + substituteVersion("org.example:beta:2.0", "org.example:beta:1.0") + } + + val alphaConfig = project.configurations.create("muzzleTestAlpha") { + isCanBeResolved = true + isCanBeConsumed = false + MuzzleVersionSubstitutionSupport.applyTo(project, this, directive) + } + project.dependencies.add(alphaConfig.name, "org.example:alpha:2.0") + val alphaResolved = alphaConfig.resolvedConfiguration.resolvedArtifacts.single() + assertThat(alphaResolved.moduleVersion.id.version).isEqualTo("1.0") + + val betaConfig = project.configurations.create("muzzleTestBeta") { + isCanBeResolved = true + isCanBeConsumed = false + MuzzleVersionSubstitutionSupport.applyTo(project, this, directive) + } + project.dependencies.add(betaConfig.name, "org.example:beta:2.0") + val betaResolved = betaConfig.resolvedConfiguration.resolvedArtifacts.single() + assertThat(betaResolved.moduleVersion.id.version).isEqualTo("1.0") + } + + @Test + fun `substituteVersion preserves rewritten pom for pom-only module`() { + val project = ProjectBuilder.builder() + .withProjectDir(tempDir.resolve("project").toFile()) + .withName("test-project") + .build() + + val repoDir = tempDir.resolve("repo") + writeModule(repoDir, "org.example", "bom", "1.0", true) + + project.repositories.maven { + url = repoDir.toUri() + } + + val directive = MuzzleDirective().apply { + substituteVersion("org.example:bom:2.0", "org.example:bom:1.0") + } + + project.configurations.create("muzzleTestPomOnly") { + isCanBeResolved = true + isCanBeConsumed = false + MuzzleVersionSubstitutionSupport.applyTo(project, this, directive) + } + + val generatedPom = tempDir.resolve( + "project/build/generated/muzzle-version-substitutions/" + + "muzzleTestPomOnly/org/example/bom/2.0/bom-2.0.pom" + ) + assertThat(generatedPom.readText()).contains("2.0") + } + + @Test + fun `substituteVersion validates coordinate format`() { + val directive = MuzzleDirective() + + org.junit.jupiter.api.assertThrows { + directive.substituteVersion("org.example:demo", "org.example:demo:1.0") + } + } + + private fun writeModule( + repoDir: Path, + group: String, + module: String, + version: String, + pomModule: Boolean = false + ) { + val moduleDir = repoDir.resolve(group.replace('.', '/')).resolve(module).resolve(version).createDirectories() + moduleDir.resolve("$module-$version.pom").writeText( + """ + + 4.0.0 + $group + $module + $version + ${if (pomModule) "pom" else ""} + + """.trimIndent() + ) + + if (!pomModule) { + ZipOutputStream(moduleDir.resolve("$module-$version.jar").outputStream()).use { } + } + } +} diff --git a/dd-java-agent/instrumentation/confluent-schema-registry/confluent-schema-registry-4.1/build.gradle b/dd-java-agent/instrumentation/confluent-schema-registry/confluent-schema-registry-4.1/build.gradle index bc8e14b0a1d..ea4b14f03a6 100644 --- a/dd-java-agent/instrumentation/confluent-schema-registry/confluent-schema-registry-4.1/build.gradle +++ b/dd-java-agent/instrumentation/confluent-schema-registry/confluent-schema-registry-4.1/build.gradle @@ -7,6 +7,13 @@ muzzle { module = "kafka-schema-registry-client" versions = "[4.1.0,)" excludeDependency "org.codehaus.jackson:jackson-mapper-asl" // missing on some releases + + // https://packages.confluent.io/maven/io/confluent/common-parent/7.5.13/common-parent-7.5.13.pom (and several others) + // ship a broken jetty.version=9.4.59 property, referencing a Jetty BOM that does not exist. + // Substitute with the closest real release. + // Remove once Confluent publishes a fixed common-parent POM. + substituteVersion "org.eclipse.jetty:jetty-bom:9.4.59", "org.eclipse.jetty:jetty-bom:9.4.58.v20250814" + assertInverse = true } } @@ -27,4 +34,3 @@ dependencies { latestDepTestImplementation group: 'io.confluent', name: 'kafka-avro-serializer', version: '+' } - diff --git a/docs/how_instrumentations_work.md b/docs/how_instrumentations_work.md index f5a99c9a9d1..5a3b7c22770 100644 --- a/docs/how_instrumentations_work.md +++ b/docs/how_instrumentations_work.md @@ -58,6 +58,8 @@ Declare necessary dependencies under `compileOnly` configuration so they do not Muzzle directives are applied at build time from the `build.gradle` file. OpenTelemetry provides some [Muzzle documentation](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/contributing/muzzle.md). Muzzle directives check for a range of framework versions that are safe to load the instrumentation. +Directives can also add repositories, append dependencies, exclude transitive dependencies, and substitute resolved +dependency coordinates for others when upstream metadata is wrong. Multiple substitutions can be declared per directive. See this excerpt as an example from [rediscala](../dd-java-agent/instrumentation/rediscala-1.8/build.gradle): @@ -76,6 +78,14 @@ muzzle { versions = "[1.8.0,)" assertInverse = true } + + pass { + group = "org.example" + module = "demo" + versions = "[2.0,)" + substituteVersion "org.example:demo-helper:2.0", "org.example:demo-helper:1.9" + substituteVersion "org.example:demo-utils:3.1", "org.example:demo-utils:3.0" + } } ```