Skip to content

Commit bb083f5

Browse files
Add substituteVersion to muzzle directives to work around broken upstream POMs.
1 parent b77fdab commit bb083f5

8 files changed

Lines changed: 335 additions & 2 deletions

File tree

buildSrc/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ testing {
106106
val test by getting(JvmTestSuite::class) {
107107
dependencies {
108108
implementation(libs.assertj.core)
109+
runtimeOnly(libs.junit.platform.launcher)
109110
}
110111
targets.configureEach {
111112
testTask.configure {
112-
enabled = providers.systemProperty("runBuildSrcTests").isPresent or providers.systemProperty("idea.active").isPresent
113+
enabled = providers.gradleProperty("runBuildSrcTests").isPresent or providers.systemProperty("idea.active").isPresent
113114
}
114115
}
115116
}

buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ open class MuzzleDirective : Serializable {
2323
var additionalDependencies: MutableList<String> = ArrayList()
2424
internal var additionalRepositories: MutableList<Triple<String, String, String>> = ArrayList()
2525
internal var excludedDependencies: MutableList<String> = ArrayList()
26+
internal var versionSubstitutions: MutableList<VersionSubstitution> = ArrayList()
2627
var assertPass: Boolean = false
2728
var assertInverse: Boolean = false
2829
var skipFromReport: Boolean = false
@@ -64,6 +65,16 @@ open class MuzzleDirective : Serializable {
6465
excludedDependencies.add(excludeString)
6566
}
6667

68+
/**
69+
* Replaces an exact dependency coordinate during muzzle Gradle resolution.
70+
*
71+
* Both parameters must be in `group:module:version` form.
72+
* May be called multiple times to register multiple substitutions.
73+
*/
74+
fun substituteVersion(requested: String, target: String) {
75+
versionSubstitutions.add(VersionSubstitution.parse(requested, target))
76+
}
77+
6778
/**
6879
* Get the list of repositories to use for this muzzle directive.
6980
*

buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ class MuzzlePlugin : Plugin<Project> {
185185
}
186186
}
187187
instrumentationProject.configurations.register(muzzleTaskName) {
188+
MuzzleVersionSubstitutionSupport.applyTo(instrumentationProject, this, muzzleDirective)
189+
188190
if (!muzzleDirective.isCoreJdk && versionArtifact != null) {
189191
val depId = buildString {
190192
append("${versionArtifact.groupId}:${versionArtifact.artifactId}:${versionArtifact.version}")
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package datadog.gradle.plugin.muzzle
2+
3+
import org.gradle.api.Project
4+
import org.gradle.api.artifacts.Configuration
5+
import java.nio.file.Files
6+
import java.nio.file.Path
7+
import java.nio.file.StandardCopyOption
8+
import kotlin.io.path.readText
9+
import kotlin.io.path.writeText
10+
11+
internal object MuzzleVersionSubstitutionSupport {
12+
fun applyTo(project: Project, configuration: Configuration, directive: MuzzleDirective) {
13+
val substitutions = directive.versionSubstitutions
14+
if (substitutions.isEmpty()) return
15+
16+
val repoDir = materializeSubstitutionRepository(project, configuration, substitutions)
17+
project.repositories.maven {
18+
name = "${configuration.name}MuzzleSubstitutions"
19+
url = repoDir.toUri()
20+
}
21+
22+
configuration.resolutionStrategy.eachDependency {
23+
val match = substitutions.firstOrNull { it.matches(requested.group, requested.name, requested.version) }
24+
if (match != null) {
25+
useTarget(match.targetNotation)
26+
because("Muzzle substituteVersion override for ${match.requestedNotation}")
27+
}
28+
}
29+
}
30+
31+
private fun materializeSubstitutionRepository(
32+
project: Project,
33+
configuration: Configuration,
34+
substitutions: List<VersionSubstitution>
35+
): Path {
36+
val repoDir = project.layout.buildDirectory
37+
.dir("generated/muzzle-version-substitutions/${configuration.name}")
38+
.get()
39+
.asFile
40+
.toPath()
41+
Files.createDirectories(repoDir)
42+
substitutions.forEach { materializeSubstitution(project, repoDir, it) }
43+
return repoDir
44+
}
45+
46+
private fun materializeSubstitution(project: Project, repoDir: Path, substitution: VersionSubstitution) {
47+
val targetPom = resolveArtifactFile(project, "${substitution.targetNotation}@pom")
48+
val destinationDir = repoDir
49+
.resolve(substitution.requestedGroup.replace('.', '/'))
50+
.resolve(substitution.requestedModule)
51+
.resolve(substitution.requestedVersion)
52+
Files.createDirectories(destinationDir)
53+
val destinationPom = destinationDir.resolve("${substitution.requestedModule}-${substitution.requestedVersion}.pom")
54+
Files.copy(
55+
targetPom,
56+
destinationPom,
57+
StandardCopyOption.REPLACE_EXISTING
58+
)
59+
rewriteProjectVersion(destinationPom, substitution)
60+
61+
resolveOptionalArtifactFile(project, substitution.targetNotation)?.let { artifactFile ->
62+
if (Files.isSameFile(targetPom, artifactFile)) {
63+
return@let
64+
}
65+
val ext = artifactFile.fileName.toString().substringAfterLast('.', "")
66+
val artifactName = "${substitution.requestedModule}-${substitution.requestedVersion}.${ext}"
67+
Files.copy(artifactFile, destinationDir.resolve(artifactName), StandardCopyOption.REPLACE_EXISTING)
68+
}
69+
}
70+
71+
private fun resolveArtifactFile(project: Project, notation: String): Path =
72+
project.configurations.detachedConfiguration(project.dependencies.create(notation)).apply {
73+
isTransitive = false
74+
}.singleFile.toPath()
75+
76+
private fun resolveOptionalArtifactFile(project: Project, notation: String): Path? =
77+
project.configurations.detachedConfiguration(project.dependencies.create(notation)).apply {
78+
isTransitive = false
79+
}.resolvedConfiguration.lenientConfiguration
80+
.artifacts
81+
.singleOrNull()
82+
?.file
83+
?.toPath()
84+
85+
private fun rewriteProjectVersion(pomFile: Path, substitution: VersionSubstitution) {
86+
val artifactIdLine = "<artifactId>${substitution.targetModule}</artifactId>"
87+
val versionLine = "<version>${substitution.targetVersion}</version>"
88+
val replacementVersionLine = "<version>${substitution.requestedVersion}</version>"
89+
val pattern = Regex("${Regex.escape(artifactIdLine)}\\s*${Regex.escape(versionLine)}")
90+
val content = pomFile.readText()
91+
val rewritten = content.replaceFirst(pattern, "$artifactIdLine\n $replacementVersionLine")
92+
check(rewritten != content) {
93+
"Could not rewrite version '${substitution.targetVersion}' to '${substitution.requestedVersion}' in POM for ${substitution.targetNotation}"
94+
}
95+
pomFile.writeText(rewritten)
96+
}
97+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package datadog.gradle.plugin.muzzle
2+
3+
import java.io.Serializable
4+
5+
internal data class VersionSubstitution(
6+
val requestedGroup: String,
7+
val requestedModule: String,
8+
val requestedVersion: String,
9+
val targetGroup: String,
10+
val targetModule: String,
11+
val targetVersion: String,
12+
) : Serializable {
13+
val requestedNotation: String
14+
get() = "$requestedGroup:$requestedModule:$requestedVersion"
15+
16+
val targetNotation: String
17+
get() = "$targetGroup:$targetModule:$targetVersion"
18+
19+
fun matches(group: String, module: String, version: String?): Boolean =
20+
requestedGroup == group && requestedModule == module && requestedVersion == version
21+
22+
companion object {
23+
fun parse(requested: String, target: String): VersionSubstitution {
24+
val requestedParts = parseCoordinate(requested)
25+
val targetParts = parseCoordinate(target)
26+
return VersionSubstitution(
27+
requestedGroup = requestedParts[0],
28+
requestedModule = requestedParts[1],
29+
requestedVersion = requestedParts[2],
30+
targetGroup = targetParts[0],
31+
targetModule = targetParts[1],
32+
targetVersion = targetParts[2],
33+
)
34+
}
35+
36+
private fun parseCoordinate(coordinate: String): List<String> {
37+
val parts = coordinate.split(":")
38+
require(parts.size == 3 && parts.none { it.isBlank() }) {
39+
"Expected dependency coordinate in 'group:module:version' form but got '$coordinate'"
40+
}
41+
return parts
42+
}
43+
}
44+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package datadog.gradle.plugin.muzzle
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.gradle.testfixtures.ProjectBuilder
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.api.io.TempDir
7+
import java.nio.file.Path
8+
import java.util.zip.ZipOutputStream
9+
import kotlin.io.path.createDirectories
10+
import kotlin.io.path.readText
11+
import kotlin.io.path.outputStream
12+
import kotlin.io.path.writeText
13+
14+
class MuzzleVersionSubstitutionSupportTest {
15+
16+
@TempDir
17+
lateinit var tempDir: Path
18+
19+
@Test
20+
fun `substituteVersion rewrites missing module version during resolution`() {
21+
val project = ProjectBuilder.builder()
22+
.withProjectDir(tempDir.resolve("project").toFile())
23+
.withName("test-project")
24+
.build()
25+
26+
val repoDir = tempDir.resolve("repo")
27+
writeModule(repoDir, "org.example", "demo", "1.0")
28+
29+
project.repositories.maven {
30+
url = repoDir.toUri()
31+
}
32+
33+
val directive = MuzzleDirective().apply {
34+
substituteVersion("org.example:demo:2.0", "org.example:demo:1.0")
35+
}
36+
37+
val configuration = project.configurations.create("muzzleTest") {
38+
isCanBeResolved = true
39+
isCanBeConsumed = false
40+
MuzzleVersionSubstitutionSupport.applyTo(project, this, directive)
41+
}
42+
project.dependencies.add(configuration.name, "org.example:demo:2.0")
43+
44+
val resolved = configuration.resolvedConfiguration.resolvedArtifacts.single()
45+
assertThat(resolved.moduleVersion.id.group).isEqualTo("org.example")
46+
assertThat(resolved.name).isEqualTo("demo")
47+
assertThat(resolved.moduleVersion.id.version).isEqualTo("1.0")
48+
}
49+
50+
@Test
51+
fun `substituteVersion supports multiple substitutions per directive`() {
52+
val project = ProjectBuilder.builder()
53+
.withProjectDir(tempDir.resolve("project").toFile())
54+
.withName("test-project")
55+
.build()
56+
57+
val repoDir = tempDir.resolve("repo")
58+
writeModule(repoDir, "org.example", "alpha", "1.0")
59+
writeModule(repoDir, "org.example", "beta", "1.0")
60+
61+
project.repositories.maven {
62+
url = repoDir.toUri()
63+
}
64+
65+
val directive = MuzzleDirective().apply {
66+
substituteVersion("org.example:alpha:2.0", "org.example:alpha:1.0")
67+
substituteVersion("org.example:beta:2.0", "org.example:beta:1.0")
68+
}
69+
70+
val alphaConfig = project.configurations.create("muzzleTestAlpha") {
71+
isCanBeResolved = true
72+
isCanBeConsumed = false
73+
MuzzleVersionSubstitutionSupport.applyTo(project, this, directive)
74+
}
75+
project.dependencies.add(alphaConfig.name, "org.example:alpha:2.0")
76+
val alphaResolved = alphaConfig.resolvedConfiguration.resolvedArtifacts.single()
77+
assertThat(alphaResolved.moduleVersion.id.version).isEqualTo("1.0")
78+
79+
val betaConfig = project.configurations.create("muzzleTestBeta") {
80+
isCanBeResolved = true
81+
isCanBeConsumed = false
82+
MuzzleVersionSubstitutionSupport.applyTo(project, this, directive)
83+
}
84+
project.dependencies.add(betaConfig.name, "org.example:beta:2.0")
85+
val betaResolved = betaConfig.resolvedConfiguration.resolvedArtifacts.single()
86+
assertThat(betaResolved.moduleVersion.id.version).isEqualTo("1.0")
87+
}
88+
89+
@Test
90+
fun `substituteVersion preserves rewritten pom for pom-only module`() {
91+
val project = ProjectBuilder.builder()
92+
.withProjectDir(tempDir.resolve("project").toFile())
93+
.withName("test-project")
94+
.build()
95+
96+
val repoDir = tempDir.resolve("repo")
97+
writePomOnlyModule(repoDir, "org.example", "bom", "1.0")
98+
99+
project.repositories.maven {
100+
url = repoDir.toUri()
101+
}
102+
103+
val directive = MuzzleDirective().apply {
104+
substituteVersion("org.example:bom:2.0", "org.example:bom:1.0")
105+
}
106+
107+
val configuration = project.configurations.create("muzzleTestPomOnly") {
108+
isCanBeResolved = true
109+
isCanBeConsumed = false
110+
MuzzleVersionSubstitutionSupport.applyTo(project, this, directive)
111+
}
112+
113+
val generatedPom = tempDir.resolve(
114+
"project/build/generated/muzzle-version-substitutions/" +
115+
"muzzleTestPomOnly/org/example/bom/2.0/bom-2.0.pom"
116+
)
117+
assertThat(generatedPom.readText()).contains("<version>2.0</version>")
118+
119+
project.dependencies.add(configuration.name, "org.example:bom:2.0@pom")
120+
val resolved = configuration.singleFile.toPath()
121+
assertThat(resolved.readText()).contains("<version>2.0</version>")
122+
}
123+
124+
@Test
125+
fun `substituteVersion validates coordinate format`() {
126+
val directive = MuzzleDirective()
127+
128+
org.junit.jupiter.api.assertThrows<IllegalArgumentException> {
129+
directive.substituteVersion("org.example:demo", "org.example:demo:1.0")
130+
}
131+
}
132+
133+
private fun writeModule(repoDir: Path, group: String, module: String, version: String) {
134+
val moduleDir = repoDir.resolve(group.replace('.', '/')).resolve(module).resolve(version).createDirectories()
135+
moduleDir.resolve("$module-$version.pom").writeText(
136+
"""
137+
<project xmlns="http://maven.apache.org/POM/4.0.0">
138+
<modelVersion>4.0.0</modelVersion>
139+
<groupId>$group</groupId>
140+
<artifactId>$module</artifactId>
141+
<version>$version</version>
142+
</project>
143+
""".trimIndent()
144+
)
145+
ZipOutputStream(moduleDir.resolve("$module-$version.jar").outputStream()).use { }
146+
}
147+
148+
private fun writePomOnlyModule(repoDir: Path, group: String, module: String, version: String) {
149+
val moduleDir = repoDir.resolve(group.replace('.', '/')).resolve(module).resolve(version).createDirectories()
150+
moduleDir.resolve("$module-$version.pom").writeText(
151+
"""
152+
<project xmlns="http://maven.apache.org/POM/4.0.0">
153+
<modelVersion>4.0.0</modelVersion>
154+
<groupId>$group</groupId>
155+
<artifactId>$module</artifactId>
156+
<version>$version</version>
157+
<packaging>pom</packaging>
158+
</project>
159+
""".trimIndent()
160+
)
161+
}
162+
}

dd-java-agent/instrumentation/confluent-schema-registry/confluent-schema-registry-4.1/build.gradle

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ muzzle {
77
module = "kafka-schema-registry-client"
88
versions = "[4.1.0,)"
99
excludeDependency "org.codehaus.jackson:jackson-mapper-asl" // missing on some releases
10+
11+
// https://packages.confluent.io/maven/io/confluent/common-parent/7.5.13/common-parent-7.5.13.pom (and several others)
12+
// ship a broken jetty.version=9.4.59 property, referencing a Jetty BOM that does not exist.
13+
// Substitute with the closest real release.
14+
// Remove once Confluent publishes a fixed common-parent POM.
15+
substituteVersion "org.eclipse.jetty:jetty-bom:9.4.59", "org.eclipse.jetty:jetty-bom:9.4.58.v20250814"
16+
1017
assertInverse = true
1118
}
1219
}
@@ -27,4 +34,3 @@ dependencies {
2734

2835
latestDepTestImplementation group: 'io.confluent', name: 'kafka-avro-serializer', version: '+'
2936
}
30-

docs/how_instrumentations_work.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Declare necessary dependencies under `compileOnly` configuration so they do not
5858
Muzzle directives are applied at build time from the `build.gradle` file.
5959
OpenTelemetry provides some [Muzzle documentation](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/contributing/muzzle.md).
6060
Muzzle directives check for a range of framework versions that are safe to load the instrumentation.
61+
Directives can also add repositories, append dependencies, exclude transitive dependencies, and substitute resolved
62+
dependency coordinates for others when upstream metadata is wrong. Multiple substitutions can be declared per directive.
6163

6264
See this excerpt as an example from [rediscala](../dd-java-agent/instrumentation/rediscala-1.8/build.gradle):
6365

@@ -76,6 +78,14 @@ muzzle {
7678
versions = "[1.8.0,)"
7779
assertInverse = true
7880
}
81+
82+
pass {
83+
group = "org.example"
84+
module = "demo"
85+
versions = "[2.0,)"
86+
substituteVersion "org.example:demo-helper:2.0", "org.example:demo-helper:1.9"
87+
substituteVersion "org.example:demo-utils:3.1", "org.example:demo-utils:3.0"
88+
}
7989
}
8090
```
8191

0 commit comments

Comments
 (0)