From 3fc05d491d0bcecd02467a45a3a6f78e47129c41 Mon Sep 17 00:00:00 2001 From: Alexey Kuznetsov Date: Tue, 18 Aug 2026 21:25:30 -0400 Subject: [PATCH] Support Maven POM overrides in Muzzle --- .../gradle/plugin/muzzle/MuzzleDirective.kt | 151 ++++++++++++++ .../plugin/muzzle/MuzzleMavenRepoUtils.kt | 194 +++++++++++++++++ .../gradle/plugin/muzzle/MuzzlePlugin.kt | 39 +++- .../plugin/muzzle/MuzzleDirectiveTest.kt | 173 ++++++++++++++++ .../plugin/muzzle/MuzzleMavenRepoUtilsTest.kt | 10 +- .../muzzle/MuzzlePluginFunctionalTest.kt | 196 ++++++++++++++++++ .../build.gradle | 28 ++- .../kafka/kafka-connect-0.11/build.gradle | 61 ++---- 8 files changed, 785 insertions(+), 67 deletions(-) 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..54406a13835 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt @@ -1,6 +1,10 @@ package datadog.gradle.plugin.muzzle +import groovy.lang.Closure import org.eclipse.aether.repository.RemoteRepository +import org.eclipse.aether.util.version.GenericVersionScheme +import org.eclipse.aether.version.InvalidVersionSpecificationException +import org.gradle.api.Action import java.io.Serializable /** @@ -23,6 +27,7 @@ open class MuzzleDirective : Serializable { var additionalDependencies: MutableList = ArrayList() internal var additionalRepositories: MutableList> = ArrayList() internal var excludedDependencies: MutableList = ArrayList() + internal var mavenPomOverrideConfig: MavenPomOverrides? = null var assertPass: Boolean = false var assertInverse: Boolean = false var skipFromReport: Boolean = false @@ -64,6 +69,27 @@ open class MuzzleDirective : Serializable { excludedDependencies.add(excludeString) } + /** + * Rewrites dependency versions declared in the published POMs of this directive's artifacts. + * + * Use this when a dependency muzzle does not care about makes a POM unresolvable, for instance when + * upstream references a version it never published. Gradle resolution rules cannot help there: a bad + * version in a parent POM or an imported BOM breaks POM parsing before any rule runs, so the POM is + * patched after download and before Maven builds the model. Artifacts matched by + * [MavenPomOverrides.artifactVersions] are consequently resolved by Maven instead of Gradle. + * Overrides apply only to this directive, not to inverse directives generated from it. + */ + fun mavenPomOverrides(action: Action) { + mavenPomOverrideConfig = MavenPomOverrides().also(action::execute).also(MavenPomOverrides::validate) + } + + internal fun requiresMavenResolution(version: String): Boolean { + val overrides = mavenPomOverrideConfig ?: return false + val versionScheme = GenericVersionScheme() + return versionScheme.parseVersionConstraint(overrides.artifactVersions) + .containsVersion(versionScheme.parseVersion(version)) + } + /** * Get the list of repositories to use for this muzzle directive. * @@ -97,3 +123,128 @@ open class MuzzleDirective : Serializable { "${if (assertPass) "pass" else "fail"} $group:$module:$versions" } } + +/** Overrides dependency versions embedded in published Maven POMs for selected artifact versions. */ +open class MavenPomOverrides : Serializable { + /** + * Maven version range selecting which of this directive's artifact versions get their POMs rewritten, + * for example `"[7.4,8)"`. Versions outside the range are resolved by Gradle as usual. Required, so + * that rewriting POMs of versions that do not need it is always a deliberate choice; use `"[,)"` to + * opt every version of the directive in. + */ + var artifactVersions: String = "" + internal val dependencyVersionOverrides: MutableMap> = + LinkedHashMap() + internal val dependencyVersionRangeOverrides: + MutableMap> = LinkedHashMap() + + /** Declares the version replacements to apply to dependencies of [group]. Matched exactly. */ + fun dependency(group: String, action: Action) { + val dependencyOverride = MavenDependencyOverride().also(action::execute) + addDependencyOverride(group, dependencyOverride) + } + + /** Groovy DSL overload of [dependency], so assignments delegate to the override. */ + fun dependency(group: String, closure: Closure<*>) { + val dependencyOverride = MavenDependencyOverride() + closure.delegate = dependencyOverride + closure.resolveStrategy = Closure.DELEGATE_FIRST + closure.call(dependencyOverride) + addDependencyOverride(group, dependencyOverride) + } + + private fun addDependencyOverride( + group: String, + dependencyOverride: MavenDependencyOverride + ) { + require( + dependencyOverride.matchVersions.isNotEmpty() || + dependencyOverride.matchPattern.isNotBlank() || + dependencyOverride.matchVersionRanges.isNotEmpty() + ) { + "At least one matchVersions, matchPattern, or matchVersionRanges entry is required " + + "for dependency group '$group'" + } + + require(dependencyOverride.replacement.isNotBlank()) { + "A replacement is required for dependency group '$group'" + } + + require("*" !in dependencyOverride.matchVersions) { + "'*' is not supported in matchVersions for dependency group '$group'; " + + "use matchVersionRanges for concrete versions" + } + + val versionScheme = GenericVersionScheme() + dependencyOverride.matchVersionRanges.forEach { range -> + try { + versionScheme.parseVersionConstraint(range) + } catch (e: InvalidVersionSpecificationException) { + throw IllegalArgumentException( + "Invalid matchVersionRanges entry '$range' for dependency group '$group'", + e + ) + } + } + + if (dependencyOverride.matchVersions.isNotEmpty() || dependencyOverride.matchPattern.isNotBlank()) { + val groupOverrides = dependencyVersionOverrides.getOrPut(group, ::LinkedHashMap) + dependencyOverride.matchVersions.forEach { groupOverrides[it] = dependencyOverride.replacement } + if (dependencyOverride.matchPattern.isNotBlank()) { + groupOverrides[dependencyOverride.matchPattern] = dependencyOverride.replacement + } + } + if (dependencyOverride.matchVersionRanges.isNotEmpty()) { + val groupOverrides = dependencyVersionRangeOverrides.getOrPut(group, ::ArrayList) + dependencyOverride.matchVersionRanges.forEach { range -> + groupOverrides.add(MavenVersionRangeOverride(range, dependencyOverride.replacement)) + } + } + } + + /** Checks the block is complete, once the whole `mavenPomOverrides { }` closure has been evaluated. */ + internal fun validate() { + require(artifactVersions.isNotBlank()) { + "artifactVersions is required in mavenPomOverrides, e.g. artifactVersions = \"[7.4,8)\", " + + "or \"[,)\" to cover every version of the directive" + } + + try { + GenericVersionScheme().parseVersionConstraint(artifactVersions) + } catch (e: InvalidVersionSpecificationException) { + throw IllegalArgumentException( + "Invalid artifactVersions Maven version range '$artifactVersions' in mavenPomOverrides", + e + ) + } + + require(dependencyVersionOverrides.isNotEmpty() || dependencyVersionRangeOverrides.isNotEmpty()) { + "At least one dependency(group) { } override is required in mavenPomOverrides" + } + } +} + + /** Version replacement values for a dependency group in [MavenPomOverrides]. */ +open class MavenDependencyOverride : Serializable { + /** + * Concrete version strings to replace, matched exactly, for example `"9.4.59"`. + * + * Use [matchPattern] for raw non-version text such as an unexpanded Maven property placeholder, + * or [matchVersionRanges] to select concrete versions with a Maven version range. + */ + var matchVersions: MutableList = ArrayList() + + /** Raw POM version text to replace literally, for example `'${jetty.version}'`. */ + var matchPattern: String = "" + + /** Maven version ranges to replace, for example `"[9.4.59,9.5)"`. */ + var matchVersionRanges: MutableList = ArrayList() + + /** Version to write into the POM in place of every match. Required. */ + var replacement: String = "" +} + +internal data class MavenVersionRangeOverride( + val range: String, + val replacement: String +) : Serializable diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt index b4f672b4d25..121f06b452b 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt @@ -1,13 +1,21 @@ package datadog.gradle.plugin.muzzle import org.apache.maven.repository.internal.MavenRepositorySystemUtils +import org.eclipse.aether.AbstractRepositoryListener +import org.eclipse.aether.DefaultRepositorySystemSession +import org.eclipse.aether.RepositoryEvent +import org.eclipse.aether.RepositoryException import org.eclipse.aether.RepositorySystem import org.eclipse.aether.RepositorySystemSession import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.DefaultArtifact +import org.eclipse.aether.collection.CollectRequest import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory +import org.eclipse.aether.graph.Dependency +import org.eclipse.aether.graph.Exclusion import org.eclipse.aether.repository.LocalRepository import org.eclipse.aether.repository.RemoteRepository +import org.eclipse.aether.resolution.DependencyRequest import org.eclipse.aether.resolution.VersionRangeRequest import org.eclipse.aether.resolution.VersionRangeResolutionException import org.eclipse.aether.resolution.VersionRangeResult @@ -15,15 +23,35 @@ import org.eclipse.aether.spi.connector.RepositoryConnectorFactory import org.eclipse.aether.spi.connector.transport.TransporterFactory import org.eclipse.aether.transport.file.FileTransporterFactory import org.eclipse.aether.transport.http.HttpTransporterFactory +import org.eclipse.aether.util.artifact.JavaScopes +import org.eclipse.aether.util.filter.DependencyFilterUtils +import org.eclipse.aether.util.listener.ChainedRepositoryListener +import org.eclipse.aether.util.version.GenericVersionScheme +import org.eclipse.aether.version.InvalidVersionSpecificationException import org.eclipse.aether.version.Version +import org.eclipse.aether.version.VersionConstraint import org.gradle.api.GradleException import org.gradle.api.logging.Logging +import java.io.File import java.nio.file.Files internal object MuzzleMavenRepoUtils { private val log = Logging.getLogger(MuzzleMavenRepoUtils::class.java) private val backoffDelaysSeconds = listOf(5L, 10L, 30L) + /** A single `` element of a POM, including `` entries. */ + private val DEPENDENCY_ELEMENT = + Regex("]*>.*?", RegexOption.DOT_MATCHES_ALL) + + /** The direct group id of a dependency, anchored before nested elements such as exclusions. */ + private val DIRECT_DEPENDENCY_GROUP = Regex( + "^]*>\\s*(?:\\s*)*\\s*([^<]*?)\\s*", + RegexOption.DOT_MATCHES_ALL + ) + + /** A direct dependency version, searched only after [DIRECT_DEPENDENCY_GROUP]. */ + private val VERSION_ELEMENT = Regex("(\\s*)([^<]*?)(\\s*)") + /** * Remote repositories used to query version ranges and fetch dependencies. * @@ -124,6 +152,172 @@ internal object MuzzleMavenRepoUtils { }.toSet() } + /** + * Resolves a Muzzle test classpath after applying dependency version overrides to its POMs. + */ + fun resolveDependencies( + muzzleDirective: MuzzleDirective, + rootArtifact: Artifact, + system: RepositorySystem, + baseSession: RepositorySystemSession, + defaultRepos: List = defaultMuzzleRepos() + ): Set { + val session = DefaultRepositorySystemSession(baseSession).apply { + repositoryListener = ChainedRepositoryListener.newInstance( + repositoryListener, + MavenDependencyVersionOverrideListener(muzzleDirective) + ) + } + + val exclusions = (muzzleDirective.excludedDependencies + listOf( + "com.sun.jdmk:jmxtools", + "com.sun.jmx:jmxri" + )).map { excluded -> + val parts = excluded.split(":", limit = 2) + Exclusion(parts[0], parts[1], "*", "*") + } + + fun dependency(artifact: Artifact): Dependency = + Dependency(artifact, JavaScopes.RUNTIME, false, exclusions) + + val collectRequest = CollectRequest().apply { + root = dependency(rootArtifact) + repositories = muzzleDirective.getRepositories(defaultRepos) + dependencies = muzzleDirective.additionalDependencies.map { dependency(DefaultArtifact(it)) } + } + + return try { + val dependencyRequest = DependencyRequest( + system.collectDependencies(session, collectRequest).root, + DependencyFilterUtils.classpathFilter(JavaScopes.COMPILE, JavaScopes.RUNTIME) + ) + system.resolveDependencies(session, dependencyRequest) + .artifactResults + .mapNotNull { it.artifact.file } + .toSet() + } catch (e: RepositoryException) { + // Without this context Gradle only reports that a file collection provider threw, which says + // nothing about which directive or which override was in play. + throw GradleException( + overriddenResolutionFailureMessage(muzzleDirective, rootArtifact, collectRequest.repositories), + e + ) + } + } + + private fun overriddenResolutionFailureMessage( + muzzleDirective: MuzzleDirective, + rootArtifact: Artifact, + repositories: List + ): String = buildString { + appendLine("Muzzle failed to resolve a test classpath with Maven POM overrides applied.") + appendLine("Artifact:") + appendLine(" ${artifactCoordinates(rootArtifact)}") + appendLine("Directive:") + appendLine(" $muzzleDirective") + appendLine("Repositories:") + repositories.forEach { appendLine(" - ${it.id}: ${it.url}") } + appendLine("POM dependency version overrides in effect:") + val overrideConfig = muzzleDirective.mavenPomOverrideConfig + val overrides = overrideConfig?.dependencyVersionOverrides.orEmpty() + val rangeOverrides = overrideConfig?.dependencyVersionRangeOverrides.orEmpty() + if (overrides.isEmpty() && rangeOverrides.isEmpty()) { + appendLine(" ") + } else { + overrides.forEach { (group, versions) -> + versions.forEach { (matched, replacement) -> appendLine(" - $group:$matched -> $replacement") } + } + rangeOverrides.forEach { (group, ranges) -> + ranges.forEach { override -> + appendLine(" - $group:${override.range} -> ${override.replacement}") + } + } + } + appendLine() + appendLine("If a dependency version above is still unresolvable, widen mavenPomOverrides for that") + append("group by adding an exact matchVersions entry or widening matchVersionRanges.") + } + + /** + * Muzzle resolves into a private temporary repository, so dependency versions can be updated + * after each POM is downloaded and before the Maven model builder reads it. + */ + private class MavenDependencyVersionOverrideListener( + directive: MuzzleDirective + ) : AbstractRepositoryListener() { + /** A dependency group to rewrite, with its Maven ranges parsed once per directive. */ + private class GroupOverride( + val exactVersions: Map, + val versionRanges: List> + ) { + fun replacementFor(version: String, versionScheme: GenericVersionScheme): String? { + exactVersions[version]?.let { return it } + val parsedVersion = try { + versionScheme.parseVersion(version) + } catch (_: InvalidVersionSpecificationException) { + return null + } + return versionRanges.firstOrNull { (range, _) -> range.containsVersion(parsedVersion) }?.second + } + } + + private val versionScheme = GenericVersionScheme() + private val groupOverrides: Map = + directive.mavenPomOverrideConfig?.let { config -> + (config.dependencyVersionOverrides.keys + config.dependencyVersionRangeOverrides.keys) + .associateWith { group -> + GroupOverride( + exactVersions = config.dependencyVersionOverrides[group].orEmpty(), + versionRanges = config.dependencyVersionRangeOverrides[group].orEmpty().map { override -> + versionScheme.parseVersionConstraint(override.range) to override.replacement + } + ) + } + }.orEmpty() + + override fun artifactResolved(event: RepositoryEvent) { + if (groupOverrides.isEmpty()) { + return + } + + val artifact = event.artifact ?: return + + if (artifact.extension != "pom") { + return + } + + val pom = event.file ?: artifact.file ?: return + + if (!pom.isFile) { + return + } + + val original = pom.readText() + val updated = DEPENDENCY_ELEMENT.replace(original) { dependency -> + val element = dependency.value + val groupMatch = DIRECT_DEPENDENCY_GROUP.find(element) ?: return@replace element + val override = groupOverrides[groupMatch.groupValues[1].trim()] ?: return@replace element + val versionMatch = VERSION_ELEMENT.find(element, groupMatch.range.last + 1) + ?: return@replace element + val replacement = override.replacementFor(versionMatch.groupValues[2].trim(), versionScheme) + ?: return@replace element + element.replaceRange( + versionMatch.range, + versionMatch.groupValues[1] + escapeXml(replacement) + versionMatch.groupValues[3] + ) + } + + if (updated != original) pom.writeText(updated) + } + + private fun escapeXml(value: String): String = value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + /** * Resolves the version range for a given MuzzleDirective using the provided RepositorySystem and RepositorySystemSession. * Equivalent to the Groovy implementation in MuzzlePlugin. 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..f75f0f42ce7 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt @@ -6,6 +6,8 @@ import datadog.gradle.plugin.muzzle.tasks.MuzzleGetReferencesTask import datadog.gradle.plugin.muzzle.tasks.MuzzleMergeReportsTask import datadog.gradle.plugin.muzzle.tasks.MuzzleTask import datadog.gradle.plugin.muzzle.planner.MuzzleTaskPlanner +import org.eclipse.aether.RepositorySystem +import org.eclipse.aether.RepositorySystemSession import org.eclipse.aether.artifact.Artifact import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Plugin @@ -123,7 +125,16 @@ class MuzzlePlugin : Plugin { val muzzleReportTasks = mutableListOf>() val directives = project.extensions.getByType().directives taskPlanner.plan(directives).forEach { plan -> - runAfter = registerMuzzleTask(plan.directive, plan.artifact, project, runAfter, muzzleBootstrap, muzzleTooling) + runAfter = registerMuzzleTask( + plan.directive, + plan.artifact, + project, + runAfter, + muzzleBootstrap, + muzzleTooling, + system, + session + ) muzzleReportTasks.add(runAfter) project.logger.info("configured ${plan.directive}") } @@ -164,7 +175,9 @@ class MuzzlePlugin : Plugin { instrumentationProject: Project, runAfterTask: TaskProvider, muzzleBootstrap: NamedDomainObjectProvider, - muzzleTooling: NamedDomainObjectProvider + muzzleTooling: NamedDomainObjectProvider, + repositorySystem: RepositorySystem, + repositorySystemSession: RepositorySystemSession ): TaskProvider { val muzzleTaskName = buildString { append("muzzle-Assert") @@ -185,6 +198,28 @@ class MuzzlePlugin : Plugin { } } instrumentationProject.configurations.register(muzzleTaskName) { + // Versions whose POMs have to be rewritten are resolved by Maven rather than Gradle, so they + // follow Maven's nearest-wins conflict resolution and ignore Gradle repositories and variant + // attributes. Every other version keeps the regular Gradle resolution below. + val mavenResolvedArtifact = versionArtifact + ?.takeIf { muzzleDirective.requiresMavenResolution(it.version) } + + if (mavenResolvedArtifact != null) { + val resolvedClasspath = instrumentationProject.providers.provider { + MuzzleMavenRepoUtils.resolveDependencies( + muzzleDirective, + mavenResolvedArtifact, + repositorySystem, + repositorySystemSession + ) + } + dependencies.add( + instrumentationProject.dependencies.create(instrumentationProject.files(resolvedClasspath)) + ) + // resolveDependencies already collects additionalDependencies into the same graph. + return@register + } + if (!muzzleDirective.isCoreJdk && versionArtifact != null) { val depId = buildString { append("${versionArtifact.groupId}:${versionArtifact.artifactId}:${versionArtifact.version}") diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt index b5c2ccaef42..ede0ddb2ecf 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalArgumentException class MuzzleDirectiveTest { @@ -159,4 +160,176 @@ class MuzzleDirectiveTest { Triple("repo2", "p2", "https://repo2.example.com"), ) } + + @Test + fun `mavenPomOverrides groups matched versions by dependency`() { + val directive = MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("com.example") { + matchVersions = mutableListOf("1.0", "2.0") + replacement = "2.1" + } + } + } + + assertThat(directive.mavenPomOverrideConfig?.artifactVersions).isEqualTo("[7.4,8)") + assertThat(directive.mavenPomOverrideConfig?.dependencyVersionOverrides).containsExactlyEntriesOf( + linkedMapOf("com.example" to linkedMapOf("1.0" to "2.1", "2.0" to "2.1")) + ) + } + + @Test + fun `mavenPomOverrides accepts Maven version ranges`() { + val directive = MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[,)" + dependency("com.example") { + matchVersionRanges = mutableListOf("[1.0,2.0)") + replacement = "2.1" + } + } + } + + assertThat(directive.mavenPomOverrideConfig?.dependencyVersionRangeOverrides).containsExactlyEntriesOf( + linkedMapOf( + "com.example" to mutableListOf(MavenVersionRangeOverride("[1.0,2.0)", "2.1")) + ) + ) + // An open range opts every version of the directive into Maven resolution. + assertThat(directive.requiresMavenResolution("1.2.3")).isTrue() + } + + @Test + fun `mavenPomOverrides accepts a literal raw POM pattern`() { + val directive = MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("org.eclipse.jetty") { + matchPattern = "\u0024{jetty.version}" + replacement = "9.4.58.v20250814" + } + } + } + + assertThat(directive.mavenPomOverrideConfig?.dependencyVersionOverrides).containsExactlyEntriesOf( + linkedMapOf( + "org.eclipse.jetty" to linkedMapOf( + "\u0024{jetty.version}" to "9.4.58.v20250814" + ) + ) + ) + } + + @Test + fun `mavenPomOverrides requires an artifact version range`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { + dependency("com.example") { + matchVersions = mutableListOf("1.0") + replacement = "2.1" + } + } + } + }.withMessageContaining("artifactVersions is required in mavenPomOverrides") + } + + @Test + fun `mavenPomOverrides rejects an invalid artifact version range`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8" + dependency("com.example") { + matchVersions = mutableListOf("1.0") + replacement = "2.1" + } + } + } + }.withMessageContaining("Invalid artifactVersions Maven version range '[7.4,8'") + } + + @Test + fun `mavenPomOverrides requires at least one dependency override`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { artifactVersions = "[7.4,8)" } + } + }.withMessageContaining("At least one dependency(group) { } override is required") + } + + @Test + fun `mavenPomOverrides rejects wildcard matches`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("com.example") { + matchVersions = mutableListOf("*") + replacement = "2.1" + } + } + } + }.withMessageContaining("'*' is not supported in matchVersions") + } + + @Test + fun `mavenPomOverrides rejects invalid Maven version ranges`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("com.example") { + matchVersionRanges = mutableListOf("[1.0,2.0") + replacement = "2.1" + } + } + } + }.withMessageContaining("Invalid matchVersionRanges entry '[1.0,2.0'") + } + + @Test + fun `mavenPomOverrides requires a dependency version selector`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("com.example") { replacement = "2.1" } + } + } + }.withMessageContaining( + "At least one matchVersions, matchPattern, or matchVersionRanges entry is required" + ) + } + + @Test + fun `mavenPomOverrides requires a replacement`() { + assertThatIllegalArgumentException().isThrownBy { + MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("com.example") { matchVersions = mutableListOf("1.0") } + } + } + }.withMessageContaining("A replacement is required") + } + + @Test + fun `requiresMavenResolution respects configured artifact version range`() { + val directive = MuzzleDirective().apply { + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("com.example") { + matchVersions = mutableListOf("1.0") + replacement = "1.1" + } + } + } + + assertThat(directive.requiresMavenResolution("7.4.14-ce")).isTrue() + assertThat(directive.requiresMavenResolution("7.9.9-ccs")).isTrue() + assertThat(directive.requiresMavenResolution("7.3.15-ce")).isFalse() + assertThat(directive.requiresMavenResolution("8.0.0-ce")).isFalse() + } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt index 97d4576d098..b37278698bf 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt @@ -160,7 +160,7 @@ class MuzzleMavenRepoUtilsTest { } @Test - fun `inverseOf returns directives outside range, inverts assertPass, and preserves properties`() { + fun `inverseOf preserves supported properties but not POM overrides`() { val repo = publishAndGetRepo("com.example", "mylib", listOf("1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0")) val directive = MuzzleDirective().apply { name = "mytest" @@ -170,6 +170,13 @@ class MuzzleMavenRepoUtilsTest { assertPass = true excludeDependency("com.other:dep") includeSnapshots = false + mavenPomOverrides { + artifactVersions = "[2.0,4.0)" + dependency("com.example") { + matchVersions = mutableListOf("1.0") + replacement = "1.1" + } + } } val result = MuzzleMavenRepoUtils.inverseOf(directive, system, newSession(), listOf(repo)) @@ -188,6 +195,7 @@ class MuzzleMavenRepoUtilsTest { assertThat(directive.module).isEqualTo("mylib") assertThat(directive.excludedDependencies).containsExactly("com.other:dep") assertThat(directive.includeSnapshots).isFalse() + assertThat(directive.mavenPomOverrideConfig).isNull() } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt index ac30e3ae447..8701ee76d5e 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt @@ -2,6 +2,7 @@ package datadog.gradle.plugin.muzzle import org.assertj.core.api.Assertions.assertThat import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.intellij.lang.annotations.Language import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource @@ -614,6 +615,201 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { .contains("Excluded dependency (guava) correctly not in test classpath") } + @Test + fun `POM overrides replace placeholder and literal dependency versions`() { + assertPomOverridesRewriteBothDependencies( + """ + mavenPomOverrides { + artifactVersions = "[1.0.0,)" + dependency("com.example.transitive") { + // Escaped '$' so the Maven property placeholder survives Kotlin string templating. + matchPattern = "\u0024{transitive.version}" + replacement = "2.0.0" + } + dependency("com.example.flattened") { + matchVersions = mutableListOf("1.0.0") + replacement = "2.0.0" + } + } + """.trim() + ) + } + + @Test + fun `POM overrides replace concrete dependency versions selected by a Maven range`() { + assertPomOverridesRewriteBothDependencies( + """ + mavenPomOverrides { + artifactVersions = "[1.0.0,)" + dependency("com.example.transitive") { + matchPattern = "\u0024{transitive.version}" + replacement = "2.0.0" + } + dependency("com.example.flattened") { + matchVersionRanges = mutableListOf("[1.0.0,2.0.0)") + replacement = "2.0.0" + } + } + """.trim() + ) + } + + /** + * Publishes an artifact whose POM declares one dependency via an unexpanded `${'$'}{transitive.version}` + * property and one via a flattened literal, then asserts [overrides] rewrites both to 2.0.0. + */ + private fun assertPomOverridesRewriteBothDependencies(@Language("kotlin") overrides: String) { + val mavenRepoFixture = createMavenRepoFixture() + mavenRepoFixture.publishVersions( + group = "com.example.test", + module = "with-transitive", + versions = listOf("1.0.0") + ) + mavenRepoFixture.publishVersions( + group = "com.example.transitive", + module = "transitive-lib", + versions = listOf("1.0.0", "2.0.0") + ) + mavenRepoFixture.publishVersions( + group = "com.example.flattened", + module = "flattened-lib", + versions = listOf("1.0.0", "2.0.0") + ) + mavenRepoFixture.publishVersions( + group = "com.example.unrelated", + module = "unrelated-lib", + versions = listOf("1.0.0", "2.0.0") + ) + + val pomFile = mavenRepoFixture.repoDir.resolve( + "com/example/test/with-transitive/1.0.0/with-transitive-1.0.0.pom" + ) + pomFile.writeText( + """ + + 4.0.0 + com.example.test + with-transitive + 1.0.0 + + 1.0.0 + + + + com.example.transitive + transitive-lib + ${'$'}{transitive.version} + + + com.example.flattened + flattened-lib + 1.0.0 + + + com.example.unrelated + unrelated-lib + 1.0.0 + + + com.example.flattened + excluded-lib + + + + + + """.trimIndent() + ) + + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + + repositories { + maven { + url = uri("${mavenRepoFixture.repoUrl}") + metadataSources { + mavenPom() + artifact() + } + } + } + + muzzle { + pass { + group = "com.example.test" + module = "with-transitive" + versions = "1.0.0" + $overrides + } + } + """ + ) + + writeScanPlugin( + """ + java.io.InputStream resource = testApplicationClassLoader.getResourceAsStream( + "META-INF/maven/com.example.transitive/transitive-lib/pom.properties"); + if (resource == null) { + throw new RuntimeException("Transitive dependency not found in test classpath"); + } + java.util.Properties properties = new java.util.Properties(); + try { + properties.load(resource); + resource.close(); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + if (!"2.0.0".equals(properties.getProperty("version"))) { + throw new RuntimeException("Expected overridden transitive version 2.0.0, found " + properties.getProperty("version")); + } + java.io.InputStream flattenedResource = testApplicationClassLoader.getResourceAsStream( + "META-INF/maven/com.example.flattened/flattened-lib/pom.properties"); + if (flattenedResource == null) { + throw new RuntimeException("Flattened dependency not found in test classpath"); + } + java.util.Properties flattenedProperties = new java.util.Properties(); + try { + flattenedProperties.load(flattenedResource); + flattenedResource.close(); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + if (!"2.0.0".equals(flattenedProperties.getProperty("version"))) { + throw new RuntimeException("Expected overridden flattened version 2.0.0, found " + flattenedProperties.getProperty("version")); + } + java.io.InputStream unrelatedResource = testApplicationClassLoader.getResourceAsStream( + "META-INF/maven/com.example.unrelated/unrelated-lib/pom.properties"); + if (unrelatedResource == null) { + throw new RuntimeException("Unrelated dependency not found in test classpath"); + } + java.util.Properties unrelatedProperties = new java.util.Properties(); + try { + unrelatedProperties.load(unrelatedResource); + unrelatedResource.close(); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + if (!"1.0.0".equals(unrelatedProperties.getProperty("version"))) { + throw new RuntimeException("Expected unrelated dependency version 1.0.0, found " + unrelatedProperties.getProperty("version")); + } + System.out.println("Overridden Maven dependency versions found in test classpath"); + """ + ) + + val result = run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.output).contains("Overridden Maven dependency versions found in test classpath") + } + @Test fun `java plugin applied after muzzle plugin`() { writeProject( 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 20f539f5abf..ec4c97aa6d3 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 @@ -6,22 +6,18 @@ muzzle { group = "io.confluent" module = "kafka-schema-registry-client" versions = "[4.1.0,)" - // broken POMs: depend on non-existent org.eclipse.jetty:jetty-bom:9.4.59 - // can be fixed after https://github.com/confluentinc/kafka-connect-storage-common/issues/468 is resolved - skipVersions += [ - '7.4.14', - '7.4.15', - '7.5.13', - '7.5.14', - '7.6.10', - '7.6.11', - '7.7.8', - '7.7.9', - '7.8.7', - '7.8.8', - '7.9.6', - '7.9.7' - ] + // Confluent 7.x parent POMs import an org.eclipse.jetty:jetty-bom that was never published, which + // breaks POM parsing before any dependency resolution rule can intervene. + // Muzzle only checks binary compatibility of the Kafka classes, so pin Jetty to the last published 9.x instead. + // Can be removed once https://github.com/confluentinc/kafka-connect-storage-common/issues/468 is resolved. + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("org.eclipse.jetty") { + matchPattern = '${jetty.version}' + matchVersionRanges = ["[9.4.59,9.5)"] + replacement = "9.4.58.v20250814" + } + } excludeDependency "org.codehaus.jackson:jackson-mapper-asl" // missing on some releases assertInverse = true } diff --git a/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle b/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle index 62bd20b2d2b..d5aa0355bd9 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle +++ b/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle @@ -6,54 +6,19 @@ muzzle { module = "connect-runtime" versions = "[0.11.0.0,)" javaVersion = "17" - // broken POMs: depend on non-existent org.eclipse.jetty:*:9.4.NN (7.x lines only; 8.x uses jetty 12) - // can be fixed after https://github.com/confluentinc/kafka-connect-storage-common/issues/468 is resolved - skipVersions += [ - '7.4.14-ce', - '7.4.14-ccs', - '7.4.15-ce', - '7.4.15-ccs', - '7.5.13-ce', - '7.5.13-ccs', - '7.5.14-ce', - '7.5.14-ccs', - '7.5.15-ce', - '7.5.15-ccs', - '7.5.16-ce', - '7.5.16-ccs', - '7.6.10-ce', - '7.6.10-ccs', - '7.6.11-ce', - '7.6.11-ccs', - '7.6.12-ce', - '7.6.12-ccs', - '7.6.13-ce', - '7.6.13-ccs', - '7.7.8-ce', - '7.7.8-ccs', - '7.7.9-ce', - '7.7.9-ccs', - '7.7.10-ce', - '7.7.10-ccs', - '7.7.11-ce', - '7.7.11-ccs', - '7.8.7-ce', - '7.8.7-ccs', - '7.8.8-ce', - '7.8.8-ccs', - '7.8.9-ce', - '7.8.9-ccs', - '7.8.10-ce', - '7.8.10-ccs', - '7.9.6-ce', - '7.9.6-ccs', - '7.9.7-ce', - '7.9.7-ccs', - '7.9.8-ce', - '7.9.8-ccs', - '7.9.9-ce', - '7.9.9-ccs' - ] + // Confluent 7.x POMs reference Jetty versions that were never published (9.4.59 and up), either + // via ${jetty.version} or as flattened literals. Muzzle only checks binary compatibility of the + // Kafka classes, so pin Jetty to the last published 9.4.x rather than skipping those releases. + // 8.x is excluded from the range because it moved to Jetty 12. + // Can be removed once https://github.com/confluentinc/kafka-connect-storage-common/issues/468 is resolved. + mavenPomOverrides { + artifactVersions = "[7.4,8)" + dependency("org.eclipse.jetty") { + matchPattern = '${jetty.version}' + matchVersionRanges = ["[9.4.59,9.5)"] + replacement = "9.4.58.v20250814" + } + } excludeDependency "io.confluent.cloud:*" excludeDependency "io.confluent.observability:*" excludeDependency "io.confluent.secure.compute:*"