diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAge.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAge.kt new file mode 100644 index 00000000000..6246aaa476b --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAge.kt @@ -0,0 +1,113 @@ +package datadog.gradle.plugin.muzzle + +import org.eclipse.aether.repository.RemoteRepository +import org.gradle.api.logging.Logging +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.time.Instant +import java.time.temporal.ChronoUnit.HOURS + +/** Publication timestamps and a fixed cutoff shared by muzzle checks in one build. */ +internal class MuzzleDependencyAge( + val minimumAgeHours: Int, + buildStartedAt: Instant, + private val lookup: (String) -> Timestamp = ::readTimestamp, + private val warn: (String) -> Unit = { log.warn(it) } +) { + data class Timestamp(val publishedAt: Instant?, val reason: String = "") + + private val cutoff = buildStartedAt.minus(minimumAgeHours.toLong(), HOURS) + private val timestamps = mutableMapOf() + private val unavailableRepositories = mutableMapOf() + + init { + require(minimumAgeHours >= 0) { "Muzzle minimum dependency age must be non-negative" } + } + + fun isEligible(group: String, module: String, version: String, repositories: List): Boolean { + if (minimumAgeHours == 0) return true + + val coordinate = "$group:$module:$version" + val pomPath = "${group.replace('.', '/')}/$module/$version/$module-$version.pom" + val failures = mutableListOf() + // Central proxies may omit Last-Modified. This fallback is only for timestamps, + // not version discovery or artifact downloads; local and custom repositories stay unchanged. + val timestampRepositories = if (repositories.any { + it.id == "central-proxy" && (it.url.startsWith("https://") || it.url.startsWith("http://")) + } + ) { + (repositories + centralTimestampRepository).distinctBy { it.url.trimEnd('/') } + } else { + repositories + } + for (repository in timestampRepositories) { + val repositoryUrl = repository.url.trimEnd('/') + val url = "$repositoryUrl/$pomPath" + val timestamp = timestamps[url] ?: unavailableRepositories[repositoryUrl] ?: timestamps.getOrPut(url) { + try { + lookup(url) + } catch (e: IOException) { + if (repositoryUrl.startsWith("https://") || repositoryUrl.startsWith("http://")) { + // Avoid repeating optional network timeouts for every sampled version in this build. + Timestamp(null, "${e.javaClass.simpleName}; timestamp lookups disabled for this repository for this build") + .also { unavailableRepositories[repositoryUrl] = it } + } else { + Timestamp(null, e.javaClass.simpleName) + } + } + } + val publishedAt = timestamp.publishedAt + if (publishedAt != null) { + if (publishedAt <= cutoff) return true + warn( + "Muzzle deferring $coordinate: published $publishedAt, eligible at " + + "${publishedAt.plus(minimumAgeHours.toLong(), HOURS)} (${minimumAgeHours}h cooldown)" + ) + return false + } + // Repository URLs and exception messages can contain credentials. + failures.add("${repository.id}: ${timestamp.reason}") + } + warn("Muzzle retaining $coordinate: cannot verify publication age (${failures.joinToString("; ")}); continuing without age verification") + return true + } + + companion object { + private val log = Logging.getLogger(MuzzleDependencyAge::class.java) + private const val TIMEOUT_MILLIS = 2_000 + private val centralTimestampRepository = RemoteRepository.Builder( + "central-timestamp", + "default", + "https://repo1.maven.org/maven2/" + ).build() + + /** Read the POM's Last-Modified header with short best-effort timeouts and no retries. */ + private fun readTimestamp(url: String): Timestamp { + val connection = URL(url).openConnection().apply { + connectTimeout = TIMEOUT_MILLIS + readTimeout = TIMEOUT_MILLIS + useCaches = false + } + try { + if (connection is HttpURLConnection) { + connection.requestMethod = "HEAD" + val status = connection.responseCode + if (status !in 200..299) { + return Timestamp(null, "HTTP $status") + } + } else { + connection.connect() + } + val modifiedAt = connection.getHeaderFieldDate("Last-Modified", -1L) + return if (modifiedAt >= 0) { + Timestamp(Instant.ofEpochMilli(modifiedAt)) + } else { + Timestamp(null, "missing or invalid Last-Modified header") + } + } finally { + if (connection is HttpURLConnection) connection.disconnect() + } + } + } +} 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 f67a76e5481..4fa5da521db 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirective.kt @@ -90,6 +90,7 @@ open class MuzzleDirective : Serializable { versions = version assertPass = !this@MuzzleDirective.assertPass excludedDependencies = this@MuzzleDirective.excludedDependencies + additionalRepositories.addAll(this@MuzzleDirective.additionalRepositories) includeSnapshots = this@MuzzleDirective.includeSnapshots } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt index 285301e2613..3e0f43a4716 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt @@ -1,15 +1,29 @@ package datadog.gradle.plugin.muzzle import org.gradle.api.Action +import org.gradle.api.GradleException import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ProviderFactory import org.gradle.kotlin.dsl.newInstance +import org.gradle.kotlin.dsl.property import java.util.Locale import javax.inject.Inject /** * Muzzle extension containing all pass and fail directives. */ -abstract class MuzzleExtension @Inject constructor(private val objectFactory: ObjectFactory) { +abstract class MuzzleExtension @Inject constructor( + private val objectFactory: ObjectFactory, + providers: ProviderFactory +) { + /** Publication cooldown; explicit DSL configuration overrides property and environment defaults. */ + val minimumDependencyAgeHours = objectFactory.property().convention( + providers.gradleProperty("muzzleMinDependencyAgeHours") + .orElse(providers.environmentVariable("MIN_DEPENDENCY_AGE_HOURS")) + .map(::parseMinimumDependencyAgeHours) + .orElse(48) + ) + val directives: MutableList = ArrayList() private val additionalRepositories: MutableList> = ArrayList() @@ -49,3 +63,8 @@ abstract class MuzzleExtension @Inject constructor(private val objectFactory: Ob directive.additionalRepositories.addAll(additionalRepositories) } } + +internal fun parseMinimumDependencyAgeHours(raw: String): Int = raw.toIntOrNull()?.takeIf { it >= 0 } + ?: throw GradleException( + "muzzleMinDependencyAgeHours / MIN_DEPENDENCY_AGE_HOURS must be a non-negative integer" + ) 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 938ad0319ff..bf037fcfb25 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt @@ -84,7 +84,8 @@ internal object MuzzleMavenRepoUtils { system: RepositorySystem, session: RepositorySystemSession, defaultRepos: List = defaultMuzzleRepos(), - random: Random = Random.Default + random: Random = Random.Default, + isEligible: ((Version) -> Boolean)? = null ): Set { val allVersionsArtifact = DefaultArtifact( muzzleDirective.group, @@ -117,7 +118,8 @@ internal object MuzzleMavenRepoUtils { allRangeResult, muzzleDirective.skipVersions, muzzleDirective.includeSnapshots, - random + random, + isEligible ).map { version -> muzzleDirective.inverse(version.toString()) }.toSet() @@ -364,18 +366,20 @@ internal object MuzzleMavenRepoUtils { /** * Convert a muzzle directive to a set of artifacts for all filtered versions. - * Throws GradleException if no artifacts are found. + * Without an eligibility filter, throws GradleException if no artifacts are found. */ fun muzzleDirectiveToArtifacts( muzzleDirective: MuzzleDirective, rangeResult: VersionRangeResult, - random: Random = Random.Default + random: Random = Random.Default, + isEligible: ((Version) -> Boolean)? = null ): Set { val versions = MuzzleVersionUtils.filterAndLimitVersions( rangeResult, muzzleDirective.skipVersions, muzzleDirective.includeSnapshots, - random + random, + isEligible ) val allVersionArtifacts = versions.map { version -> DefaultArtifact( @@ -386,7 +390,7 @@ internal object MuzzleMavenRepoUtils { version.toString() ) }.toSet() - if (allVersionArtifacts.isEmpty()) { + if (allVersionArtifacts.isEmpty() && isEligible == null) { throw GradleException("No muzzle artifacts found for ${muzzleDirective.group}:${muzzleDirective.module} ${muzzleDirective.versions} ${muzzleDirective.classifier}") } return allVersionArtifacts 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 9f1a255ee0c..083e690f8ef 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt @@ -10,6 +10,7 @@ import datadog.gradle.plugin.muzzle.tasks.MuzzleGenerateReportTask import datadog.gradle.plugin.muzzle.tasks.MuzzleGetReferencesTask import datadog.gradle.plugin.muzzle.tasks.MuzzleTask import kotlin.random.Random +import org.eclipse.aether.RepositorySystemSession import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.DefaultArtifact import org.gradle.api.NamedDomainObjectProvider @@ -26,6 +27,7 @@ import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.project import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType +import java.time.Instant import javax.inject.Inject /** @@ -43,7 +45,7 @@ class MuzzlePlugin @Inject constructor(private val buildFeatures: BuildFeatures) */ override fun apply(project: Project) { // create extension first, if java plugin is applied after muzzle - project.extensions.create("muzzle", project.objects) + project.extensions.create("muzzle", project.objects, project.providers) // Configure muzzle only when java plugin is applied, because this plugin requires // the project's SourceSetContainer, which created by the java plugin (via the JvmEcosystemPlugin) @@ -159,14 +161,15 @@ class MuzzlePlugin @Inject constructor(private val buildFeatures: BuildFeatures) } private fun muzzlePlans(project: Project, directives: List): List { + val minimumAgeHours = project.extensions.getByType().minimumDependencyAgeHours.get() if (buildFeatures.configurationCache.active.get()) { // Only selected modules become cache inputs; revalidate their coordinates on cache reuse. return project.providers.of(MuzzlePlansValueSource::class.java) { - parameters.requests.set(listOf(MuzzlePlanningRequest(project.path, directives))) + parameters.requests.set(listOf(MuzzlePlanningRequest(project.path, minimumAgeHours, directives))) parameters.samplingSeed.set(Random.nextLong()) }.get() } - val planner = sharedTaskPlanner(project) + val planner = sharedTaskPlanner(project, minimumAgeHours) return directives.flatMapIndexed { index, directive -> planner.plan(listOf(directive)).map { plan -> MuzzlePlannedVersion(project.path, index, plan.artifact?.version, plan.directive.assertPass) @@ -174,22 +177,33 @@ class MuzzlePlugin @Inject constructor(private val buildFeatures: BuildFeatures) } } - private fun sharedTaskPlanner(project: Project): MuzzleTaskPlanner { + private fun sharedTaskPlanner(project: Project, minimumAgeHours: Int): MuzzleTaskPlanner { val properties = project.rootProject.extensions.extraProperties - val key = "datadogMuzzleTaskPlanners" + val key = "datadogMuzzlePlanning" if (!properties.has(key)) { - properties.set(key, mutableMapOf>>, MuzzleTaskPlanner>()) + properties.set(key, SharedMuzzlePlanning()) } - @Suppress("UNCHECKED_CAST") - val planners = properties.get(key) as MutableMap>>, MuzzleTaskPlanner> + val planning = properties.get(key) as SharedMuzzlePlanning // Repository IDs can be reused for different URLs, so keep their local metadata separate. val repositories = project.extensions.getByType().directives .map { it.additionalRepositories.toList() }.distinct() - return planners.getOrPut(repositories) { - val system = MuzzleMavenRepoUtils.newRepositorySystem() - val session = MuzzleMavenRepoUtils.newRepositorySystemSession(system) - MuzzleTaskPlanner(MavenMuzzleResolutionService(system, session)) - } + return planning.planner(repositories, minimumAgeHours) + } + + /** Shares repository sessions and a fixed cutoff while respecting each module's cooldown. */ + private class SharedMuzzlePlanning { + private val system = MuzzleMavenRepoUtils.newRepositorySystem() + private val startedAt = Instant.now() + private val sessions = mutableMapOf>>, RepositorySystemSession>() + private val ages = mutableMapOf() + private val planners = mutableMapOf>>, Int>, MuzzleTaskPlanner>() + + fun planner(repositories: List>>, minimumAgeHours: Int): MuzzleTaskPlanner = + planners.getOrPut(repositories to minimumAgeHours) { + val session = sessions.getOrPut(repositories) { MuzzleMavenRepoUtils.newRepositorySystemSession(system) } + val age = ages.getOrPut(minimumAgeHours) { MuzzleDependencyAge(minimumAgeHours, startedAt) } + MuzzleTaskPlanner(MavenMuzzleResolutionService(system, session, age)) + } } companion object { diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt index eb6dbda957e..76b83e400d0 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt @@ -21,10 +21,15 @@ internal object MuzzleVersionUtils { result: VersionRangeResult, skipVersions: Set, includeSnapshots: Boolean, - random: Random = Random.Default + random: Random = Random.Default, + isEligible: ((Version) -> Boolean)? = null ): Set { val filtered = filterVersion(result.versions.toSet(), skipVersions, includeSnapshots) - return limitLargeRanges(result, filtered, skipVersions, random) + if (isEligible == null) return limitLargeRanges(result, filtered, skipVersions, random) + val candidates = filtered.filterNot { skipVersions.contains(it.toString()) } + val selected = VersionSet(candidates).sampleEligibleBoundaries(RANGE_COUNT_LIMIT - 1, random, isEligible) + if (filtered.size > selected.size) println("Muzzle skipping ${filtered.size - selected.size} versions") + return selected } /** diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSet.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSet.kt index 2574163f5ad..19c05ae523d 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSet.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/VersionSet.kt @@ -2,13 +2,11 @@ package datadog.gradle.plugin.muzzle import org.eclipse.aether.version.Version import java.util.SortedSet +import kotlin.random.Random -class VersionSet(versions: Collection) { - private val sortedVersions: SortedSet = sortedSetOf() - - init { - versions.forEach { sortedVersions.add(ParsedVersion(it)) } - } +class VersionSet(private val versions: Collection) { + private val parsedVersions = versions.map(::ParsedVersion) + private val sortedVersions: SortedSet = parsedVersions.toSortedSet() val lowAndHighForMajorMinor: List get() { @@ -30,6 +28,41 @@ class VersionSet(versions: Collection) { return resultSet.map { it.version } } + /** Sample boundaries lazily, backfilling deferred releases and retaining eligible range extrema. */ + internal fun sampleEligibleBoundaries( + limit: Int, + random: Random, + isEligible: (Version) -> Boolean + ): Set { + val eligibility = mutableMapOf() + fun eligible(version: Version) = eligibility.getOrPut(version) { isEligible(version) } + val ordered = versions.sorted() + val lowest = ordered.firstOrNull(::eligible) ?: return emptySet() + val highest = ordered.last(::eligible) + val selected = linkedSetOf(lowest, highest) + val groups = parsedVersions.sorted().groupBy { it.majorMinor } + // Preserve the existing seeded sample when all boundaries are eligible. + for (boundary in lowAndHighForMajorMinor.shuffled(random).asReversed()) { + if (selected.size >= limit) break + val parsed = ParsedVersion(boundary) + val group = groups.getValue(parsed.majorMinor) + val replacement = if (parsed == group.first()) { + group.firstOrNull { eligible(it.version) }?.version + } else { + // Equivalent parsed versions can have different repository spellings and timestamps. + // Keep the first eligible spelling of the highest eligible parsed version. + var highestEligible: ParsedVersion? = null + for (candidate in group.asReversed()) { + if (highestEligible != null && candidate.compareTo(highestEligible) != 0) break + if (eligible(candidate.version)) highestEligible = candidate + } + highestEligible?.version + } + replacement?.let(selected::add) + } + return selected + } + internal class ParsedVersion(val version: Version) : Comparable { companion object { private val dotPattern = Regex("\\.") diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt index 376c0d3f3f6..ad1821484a3 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt @@ -1,10 +1,14 @@ package datadog.gradle.plugin.muzzle.planner +import datadog.gradle.plugin.muzzle.MuzzleDependencyAge import datadog.gradle.plugin.muzzle.MuzzleDirective import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils import org.eclipse.aether.RepositorySystem import org.eclipse.aether.RepositorySystemSession import org.eclipse.aether.artifact.Artifact +import org.eclipse.aether.repository.RemoteRepository +import org.eclipse.aether.version.Version +import org.gradle.api.GradleException import kotlin.random.Random /** @@ -13,13 +17,33 @@ import kotlin.random.Random internal class MavenMuzzleResolutionService( private val system: RepositorySystem, private val session: RepositorySystemSession, - private val random: Random = Random.Default, + private val dependencyAge: MuzzleDependencyAge, + private val defaultRepos: List = MuzzleMavenRepoUtils.defaultMuzzleRepos(), + private val random: Random = Random.Default ) : MuzzleResolutionService { override fun resolveArtifacts(directive: MuzzleDirective): Set { - val range = MuzzleMavenRepoUtils.resolveVersionRange(directive, system, session) - return MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts(directive, range, random) + val range = MuzzleMavenRepoUtils.resolveVersionRange(directive, system, session, defaultRepos) + return MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts( + directive, range, random = random, isEligible = eligibility(directive) + ).ifEmpty { + throw GradleException( + "No eligible muzzle artifacts for ${directive.group}:${directive.module} ${directive.versions} " + + "after version exclusions and ${dependencyAge.minimumAgeHours}h publication cooldown. " + + "See deferred-version warnings; use -PmuzzleMinDependencyAgeHours=0 to bypass the cooldown." + ) + } } override fun inverseOf(directive: MuzzleDirective): Set = - MuzzleMavenRepoUtils.inverseOf(directive, system, session, random = random) + MuzzleMavenRepoUtils.inverseOf( + directive, system, session, defaultRepos, random = random, isEligible = eligibility(directive) + ) + + private fun eligibility(directive: MuzzleDirective): ((Version) -> Boolean)? { + if (dependencyAge.minimumAgeHours == 0) return null + val group = requireNotNull(directive.group) + val module = requireNotNull(directive.module) + val repositories = directive.getRepositories(defaultRepos) + return { version -> dependencyAge.isEligible(group, module, version.toString(), repositories) } + } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzlePlansValueSource.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzlePlansValueSource.kt index 655d2f84c7e..9dde88b880a 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzlePlansValueSource.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzlePlansValueSource.kt @@ -1,5 +1,6 @@ package datadog.gradle.plugin.muzzle.planner +import datadog.gradle.plugin.muzzle.MuzzleDependencyAge import datadog.gradle.plugin.muzzle.MuzzleDirective import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils import org.eclipse.aether.DefaultRepositorySystemSession @@ -10,6 +11,7 @@ import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import java.io.Serializable +import java.time.Instant import kotlin.random.Random /** Tracks the selected coordinates as configuration inputs. */ @@ -21,24 +23,27 @@ internal abstract class MuzzlePlansValueSource : val samplingSeed: Property } - override fun getDisplayName() = "Muzzle coordinates" + override fun getDisplayName() = "eligible Muzzle coordinates" override fun obtain(): List { val startNanos = System.nanoTime() val system = MuzzleMavenRepoUtils.newRepositorySystem() + val startedAt = Instant.now() + val ages = mutableMapOf() val random = Random(parameters.samplingSeed.get()) val requests = parameters.requests.get() - val planners = mutableMapOf>>, MuzzleTaskPlanner>() + val sessions = mutableMapOf>>, DefaultRepositorySystemSession>() val plans = requests.flatMap { request -> // Repository IDs can be reused for different URLs, so keep their local metadata separate. val repositories = request.directives.map { it.additionalRepositories.toList() }.distinct() - val planner = planners.getOrPut(repositories) { - val session = DefaultRepositorySystemSession(MuzzleMavenRepoUtils.newRepositorySystemSession(system)).apply { + val session = sessions.getOrPut(repositories) { + DefaultRepositorySystemSession(MuzzleMavenRepoUtils.newRepositorySystemSession(system)).apply { // Background lookups escape Gradle's ValueSource input tracking and expose temporary files. setConfigProperty("aether.metadataResolver.threads", 1) } - MuzzleTaskPlanner(MavenMuzzleResolutionService(system, session, random = random)) } + val age = ages.getOrPut(request.minimumAgeHours) { MuzzleDependencyAge(request.minimumAgeHours, startedAt) } + val planner = MuzzleTaskPlanner(MavenMuzzleResolutionService(system, session, age, random = random)) request.directives.flatMapIndexed { index, directive -> planner.plan(listOf(directive)).map { plan -> MuzzlePlannedVersion(request.projectPath, index, plan.artifact?.version, plan.directive.assertPass) @@ -55,6 +60,7 @@ internal abstract class MuzzlePlansValueSource : internal data class MuzzlePlanningRequest( val projectPath: String, + val minimumAgeHours: Int, val directives: List ) : Serializable diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt index 02216f1c7fb..3bc8951bc5e 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt @@ -3,6 +3,7 @@ package datadog.gradle.plugin import java.io.File import java.nio.file.Path import java.security.MessageDigest +import java.time.Instant import java.util.jar.JarOutputStream /** @@ -30,12 +31,14 @@ class MavenRepoFixture(projectDir: File) { * @param group Maven group ID * @param module Maven artifact ID * @param versions List of versions to publish (will be merged with existing versions) + * @param publishedAt Publication time for the POMs; defaults to established releases * @param jarContentBuilder Optional lambda to add entries to the JAR */ fun publishVersions( group: String, module: String, versions: List, + publishedAt: Instant = Instant.parse("2000-01-01T00:00:00Z"), jarContentBuilder: ((JarOutputStream) -> Unit)? = null ) { require(versions.isNotEmpty()) { "versions must not be empty" } @@ -45,6 +48,7 @@ class MavenRepoFixture(projectDir: File) { // Create all version artifacts versions.forEach { version -> createMavenVersion(moduleDir, group, module, version, jarContentBuilder) + check(File(moduleDir, "$version/$module-$version.pom").setLastModified(publishedAt.toEpochMilli())) } // Read existing versions from metadata and merge with new versions diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAgeFunctionalTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAgeFunctionalTest.kt new file mode 100644 index 00000000000..3411d43f7f7 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAgeFunctionalTest.kt @@ -0,0 +1,217 @@ +package datadog.gradle.plugin.muzzle + +import datadog.gradle.plugin.MavenRepoFixture +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome.FAILED +import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.time.Instant + +class MuzzleDependencyAgeFunctionalTest : MuzzlePluginTestFixture() { + private lateinit var fixture: MavenRepoFixture + private val task = ":dd-java-agent:instrumentation:demo:muzzle" + private val assertionPrefix = "$task-AssertPass-com.example.test-demo-lib-" + + @BeforeEach + fun setup() { + fixture = createMavenRepoFixture() + writeNoopScanPlugin() + } + + @Test + fun `default cooldown selects eligible versions across modules`() { + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.0.0")) + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.1.0"), publishedAt = Instant.now()) + val script = libraryProject(fixture) + writeProject(script) + addSubproject("dd-java-agent:instrumentation:other", script) + + val result = runMuzzle(requestedTask = "muzzle", hours = null) + + assertThat(result.output).contains("BUILD SUCCESSFUL", "48h cooldown", "com.example.test:demo-lib:1.1.0") + for (project in listOf("demo", "other")) { + val prefix = ":dd-java-agent:instrumentation:$project:muzzle-AssertPass-com.example.test-demo-lib-" + assertThat(result.task("${prefix}1.0.0")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task("${prefix}1.1.0")).isNull() + } + } + + @Test + fun `checks the dependency when its publication age cannot be verified`() { + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.0.0")) + // File URLs omit Last-Modified when the modification time is zero. + val pom = fixture.repoDir.resolve("com/example/test/demo-lib/1.0.0/demo-lib-1.0.0.pom") + check(pom.setLastModified(0L)) + writeProject(libraryProject(fixture)) + + val result = runMuzzle() + + assertThat(result.output).contains("BUILD SUCCESSFUL", "Muzzle retaining com.example.test:demo-lib:1.0.0", "cannot verify publication age") + assertThat(result.task("${assertionPrefix}1.0.0")?.outcome).isEqualTo(SUCCESS) + } + + @Test + fun `zero property overrides the environment and checks fresh releases`() { + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.0.0"), publishedAt = Instant.now()) + writeProject(libraryProject(fixture)) + + val result = runMuzzle("-PmuzzleMinDependencyAgeHours=0", hours = "96") + + assertThat(result.task("${assertionPrefix}1.0.0")?.outcome).isEqualTo(SUCCESS) + assertThat(result.output).doesNotContain("Muzzle deferring") + } + + @Test + fun `reused daemon reads cooldown configuration from each build`() { + fixture.publishVersions( + "com.example.test", + "demo-lib", + listOf("1.0.0"), + publishedAt = Instant.now().minusSeconds(72 * 3600L) + ) + writeProject(libraryProject(fixture)) + + val deferred = runMuzzle(expectFailure = true, hours = "96") + assertThat(deferred.output).contains("No eligible muzzle artifacts", "96h publication cooldown") + + val eligible = runMuzzle() + assertThat(eligible.task("${assertionPrefix}1.0.0")?.outcome).isEqualTo(SUCCESS) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `cached graphs revalidate newly eligible releases`(incompatible: Boolean) { + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.0.0")) + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.1.0"), publishedAt = Instant.now()) + writeProject(libraryProject(fixture)) + if (incompatible) { + writeScanPlugin( + """ + try (java.io.InputStream input = testApplicationClassLoader.getResourceAsStream( + "META-INF/maven/com.example.test/demo-lib/pom.properties")) { + java.util.Properties properties = new java.util.Properties(); + properties.load(input); + if ("1.1.0".equals(properties.getProperty("version"))) { + throw new IllegalStateException("Newly eligible version is incompatible"); + } + } catch (java.io.IOException e) { + throw new AssertionError(e); + } + """ + ) + } + + val first = runMuzzle("--configuration-cache") + assertThat(first.output).contains("BUILD SUCCESSFUL", "Configuration cache entry stored") + assertThat(first.task("${assertionPrefix}1.1.0")).isNull() + + val unchanged = runMuzzle("--configuration-cache") + assertThat(unchanged.output).contains("BUILD SUCCESSFUL", "Reusing configuration cache") + assertThat(unchanged.task("${assertionPrefix}1.0.0")?.outcome).isEqualTo(UP_TO_DATE) + assertThat(unchanged.task("${assertionPrefix}1.1.0")).isNull() + + val pom = fixture.repoDir.resolve("com/example/test/demo-lib/1.1.0/demo-lib-1.1.0.pom") + check(pom.setLastModified(Instant.now().minusSeconds(72 * 3600L).toEpochMilli())) + + val second = runMuzzle("--configuration-cache", expectFailure = incompatible) + assertThat(second.output).doesNotContain("Reusing configuration cache") + .contains(if (incompatible) "Muzzle validation failed" else "BUILD SUCCESSFUL") + assertThat(second.task("${assertionPrefix}1.1.0")?.outcome).isEqualTo(if (incompatible) FAILED else SUCCESS) + if (incompatible) { + assertThat(resultFile("muzzle-AssertPass-com.example.test-demo-lib-1.1.0").toFile().readText()) + .contains("Newly eligible version is incompatible") + } + } + + @Test + fun `DSL cooldown overrides lazily configured property and environment defaults`() { + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.0.0"), publishedAt = Instant.now()) + writeProject(libraryProject(fixture) + "\nmuzzle { minimumDependencyAgeHours.set(0) }") + + val result = runMuzzle("-PmuzzleMinDependencyAgeHours=invalid", hours = "invalid") + + assertThat(result.output).contains("BUILD SUCCESSFUL").doesNotContain("Muzzle deferring") + assertThat(result.task("${assertionPrefix}1.0.0")?.outcome).isEqualTo(SUCCESS) + } + + @Test + fun `each module may override its cooldown without changing other modules`() { + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.0.0")) + fixture.publishVersions("com.example.test", "demo-lib", listOf("1.1.0"), publishedAt = Instant.now()) + writeProject(libraryProject(fixture) + "\nmuzzle { minimumDependencyAgeHours.set(0) }") + addSubproject("dd-java-agent:instrumentation:other", libraryProject(fixture)) + + val result = runMuzzle(requestedTask = "muzzle") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task("${assertionPrefix}1.1.0")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:other:muzzle-AssertPass-com.example.test-demo-lib-1.1.0")).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["compileMuzzle", "compileJava"]) + fun `compilation does not resolve versions or cooldown configuration`(compilationTask: String) { + writeProject(libraryProject(fixture).replace("demo-lib", "absent-lib")) + + val result = runMuzzle("--configuration-cache", requestedTask = ":dd-java-agent:instrumentation:demo:$compilationTask", hours = "invalid") + + assertThat(result.output).contains("BUILD SUCCESSFUL", "Configuration cache entry stored") + .doesNotContain("Muzzle retaining", "Muzzle deferring", "Muzzle version range resolution failed") + } + + @Test + fun `zero cooldown and large sampled ranges reuse an unchanged configuration cache`() { + fixture.publishVersions("com.example.test", "demo-lib", (0..49).map { "1.$it.0" }, publishedAt = Instant.now()) + writeProject(libraryProject(fixture) + "\nmuzzle { minimumDependencyAgeHours.set(0) }") + + val first = runMuzzle("--configuration-cache") + val second = runMuzzle("--configuration-cache") + + assertThat(first.output).contains("BUILD SUCCESSFUL", "Configuration cache entry stored") + assertThat(second.output).contains("BUILD SUCCESSFUL", "Reusing configuration cache") + assertThat(first.tasks.filter { it.path.startsWith(assertionPrefix) }).hasSize(24) + assertThat(second.tasks.filter { it.path.startsWith(assertionPrefix) }.map { it.path }) + .containsExactlyElementsOf(first.tasks.filter { it.path.startsWith(assertionPrefix) }.map { it.path }) + } + + @Test + fun `invalid cooldown fails before dependency resolution`() { + writeProject(libraryProject(fixture)) + + val result = runMuzzle("-PmuzzleMinDependencyAgeHours=-1", expectFailure = true) + + assertThat(result.output).contains("must be a non-negative integer") + .doesNotContain("Muzzle version range resolution failed") + } + + private fun runMuzzle( + vararg arguments: String, + requestedTask: String = task, + hours: String? = "48", + expectFailure: Boolean = false + ) = run( + requestedTask, *arguments, expectFailure = expectFailure, + env = mapOf("MAVEN_REPOSITORY_PROXY" to fixture.repoUrl) + + if (hours == null) emptyMap() else mapOf("MIN_DEPENDENCY_AGE_HOURS" to hours), + unsetEnv = if (hours == null) setOf("MIN_DEPENDENCY_AGE_HOURS") else emptySet() + ) + + private fun libraryProject(fixture: MavenRepoFixture) = """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + repositories { maven { url = uri("${fixture.repoUrl}") } } + muzzle { + pass { + group = "com.example.test" + module = "demo-lib" + versions = "[1.0,2.0)" + } + } + """ +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAgeTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAgeTest.kt new file mode 100644 index 00000000000..356334d6777 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDependencyAgeTest.kt @@ -0,0 +1,421 @@ +package datadog.gradle.plugin.muzzle + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import datadog.gradle.plugin.muzzle.MuzzleDependencyAge.Timestamp +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.eclipse.aether.repository.RemoteRepository +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.io.FileNotFoundException +import java.net.ConnectException +import java.net.InetSocketAddress +import java.net.SocketTimeoutException +import java.time.Instant +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.SECONDS +import java.util.concurrent.atomic.AtomicInteger + +class MuzzleDependencyAgeTest { + private val now = Instant.parse("2026-09-30T12:00:00Z") + private val repositories = listOf(repository("central", "https://repo.example/maven2")) + private val proxy = repository("central-proxy", "https://proxy.example/maven2/") + private val centralPom = "https://repo1.maven.org/maven2/com/example/lib/1.0/lib-1.0.pom" + private val requested = mutableListOf() + private val warnings = mutableListOf() + + @Test + fun `accepts the exact cutoff and older versions but defers newer versions`() { + val policy = policy { url -> + Timestamp( + when { + url.contains("/1.0/") -> now.minusSeconds(48 * 3600L + 1) + url.contains("/1.1/") -> now.minusSeconds(48 * 3600L) + else -> now.minusSeconds(48 * 3600L - 1) + } + ) + } + + assertThat(eligible(policy, "1.0")).isTrue() + assertThat(eligible(policy, "1.1")).isTrue() + assertThat(eligible(policy, "1.2")).isFalse() + assertThat(warnings).singleElement().asString() + .contains("com.example:lib:1.2", "eligible at 2026-09-30T12:00:01Z", "48h cooldown") + } + + @ParameterizedTest + @CsvSource(value = ["central,true", "central-proxy,false"]) + fun `uses configured extra repositories before any Central fallback`(primaryId: String, oldEnough: Boolean) { + val primary = if (primaryId == "central-proxy") proxy else repositories.single() + val extra = repository("extra", "https://extra.example/maven2/") + val policy = policy { url -> + if (url.startsWith(primary.url)) { + Timestamp(null, "HTTP 404") + } else { + Timestamp(if (oldEnough) now.minusSeconds(72 * 3600L) else now) + } + } + + assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(primary, extra))).isEqualTo(oldEnough) + assertThat(requested).containsExactly( + "${primary.url.trimEnd('/')}/com/example/lib/1.0/lib-1.0.pom", + "${extra.url}com/example/lib/1.0/lib-1.0.pom" + ) + if (oldEnough) assertThat(warnings).isEmpty() + else assertThat(warnings).singleElement().asString().contains("Muzzle deferring com.example:lib:1.0") + } + + @ParameterizedTest + @ValueSource(longs = [172799L, 172800L, 172801L]) + fun `Central timestamp fallback enforces the cooldown when proxy headers are missing`(ageSeconds: Long) { + val policy = policy { url -> + if (url == centralPom) { + Timestamp(now.minusSeconds(ageSeconds)) + } else { + Timestamp(null, "missing or invalid Last-Modified header") + } + } + + assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(proxy))) + .isEqualTo(ageSeconds >= 48 * 3600L) + assertThat(requested).containsExactly( + "https://proxy.example/maven2/com/example/lib/1.0/lib-1.0.pom", + centralPom + ) + if (ageSeconds < 48 * 3600L) { + assertThat(warnings).singleElement().asString().contains("Muzzle deferring com.example:lib:1.0", "48h cooldown") + } else { + assertThat(warnings).isEmpty() + } + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `does not contact Central when the proxy provides a usable timestamp`(oldEnough: Boolean) { + val policy = policy { url -> + assertThat(url).startsWith(proxy.url) + Timestamp(if (oldEnough) now.minusSeconds(72 * 3600L) else now) + } + + assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(proxy))).isEqualTo(oldEnough) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `caches proxy and Central timestamps including unavailable timestamps`(available: Boolean) { + val policy = policy { url -> + if (url == centralPom) { + if (available) Timestamp(now.minusSeconds(72 * 3600L)) else Timestamp(null, "HTTP 429") + } else Timestamp(null, "missing or invalid Last-Modified header") + } + repeat(2) { assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(proxy))).isTrue() } + assertThat(requested).containsExactly( + "https://proxy.example/maven2/com/example/lib/1.0/lib-1.0.pom", centralPom + ) + if (available) assertThat(warnings).isEmpty() + else assertThat(warnings).hasSize(2).allSatisfy { + assertThat(it).contains("Muzzle retaining com.example:lib:1.0", "cannot verify publication age", "central-timestamp: HTTP 429") + } + } + + @Test + fun `does not repeat unreachable Central timestamp requests for different versions`() { + val policy = policy { url -> + if (url.startsWith(proxy.url)) { + Timestamp(null, "missing or invalid Last-Modified header") + } else { + throw SocketTimeoutException("https://secret-token.example/") + } + } + + repeat(100) { version -> + assertThat(policy.isEligible("com.example", "lib", "1.$version", listOf(proxy))).isTrue() + } + + assertThat(requested.count { it.startsWith(proxy.url) }).isEqualTo(100) + assertThat(requested.count { it.startsWith("https://repo1.maven.org/") }).isEqualTo(1) + assertThat(warnings).hasSize(100).allSatisfy { + assertThat(it).contains( + "cannot verify publication age", + "central-timestamp: SocketTimeoutException", + "timestamp lookups disabled for this repository for this build", + "continuing without age verification" + ).doesNotContain("secret-token") + } + } + + @Test + fun `continues using proxy timestamps after the Central timestamp fallback times out`() { + val policy = policy { url -> + when { + !url.startsWith(proxy.url) -> throw SocketTimeoutException() + url.contains("/1.0/") -> Timestamp(null, "missing or invalid Last-Modified header") + else -> Timestamp(now) + } + } + + assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(proxy))).isTrue() + assertThat(policy.isEligible("com.example", "lib", "1.1", listOf(proxy))).isFalse() + assertThat(requested).hasSize(3) + assertThat(warnings[1]).contains("Muzzle deferring com.example:lib:1.1") + } + + @Test + fun `continues trying healthy repositories after a connection failure`() { + val policy = policy { url -> + if (url.startsWith("https://repo.example/")) throw ConnectException() + Timestamp(now) + } + val repos = repositories + repository("extra", "https://extra.example/maven2/") + + repeat(2) { version -> + assertThat(policy.isEligible("com.example", "lib", "1.$version", repos)).isFalse() + } + + assertThat(requested.count { it.startsWith("https://repo.example/") }).isEqualTo(1) + assertThat(requested.count { it.startsWith("https://extra.example/") }).isEqualTo(2) + assertThat(warnings).hasSize(2).allSatisfy { assertThat(it).contains("Muzzle deferring") } + } + + @Test + fun `a subsequent build retries repositories after transport failures`() { + val lookup: (String) -> Timestamp = { + if (requested.size == 1) throw SocketTimeoutException() + Timestamp(now) + } + val first = policy(lookup) + val second = policy(lookup) + + assertThat(eligible(first, "1.0")).isTrue() + assertThat(eligible(first, "1.1")).isTrue() + assertThat(eligible(second, "1.0")).isFalse() + assertThat(requested).hasSize(2) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `retains cached publication timestamps when another version has a transport failure`(oldEnough: Boolean) { + val policy = policy { + if (requested.size == 1) { + Timestamp(if (oldEnough) now.minusSeconds(72 * 3600L) else now) + } else { + throw SocketTimeoutException() + } + } + + assertThat(eligible(policy, "1.0")).isEqualTo(oldEnough) + assertThat(eligible(policy, "1.1")).isTrue() + assertThat(eligible(policy, "1.0")).isEqualTo(oldEnough) + assertThat(requested).hasSize(2) + } + + @ParameterizedTest + @ValueSource(strings = ["HTTP 404", "missing or invalid Last-Modified header"]) + fun `artifact-specific missing timestamps do not disable a repository`(reason: String) { + val policy = policy { + if (requested.size == 1) Timestamp(null, reason) else Timestamp(now) + } + + assertThat(eligible(policy, "1.0")).isTrue() + assertThat(eligible(policy, "1.1")).isFalse() + assertThat(requested).hasSize(2) + } + + @Test + fun `a missing local file does not disable timestamps for other local versions`() { + val policy = policy { + if (requested.size == 1) throw FileNotFoundException() + Timestamp(now) + } + val repos = listOf(repository("fixture", "file:/tmp/muzzle-repo/")) + + assertThat(policy.isEligible("com.example", "lib", "1.0", repos)).isTrue() + assertThat(policy.isEligible("com.example", "lib", "1.1", repos)).isFalse() + assertThat(requested).hasSize(2) + } + + @Test + fun `does not duplicate Central timestamp requests when Central is already configured`() { + val policy = policy { url -> + Timestamp(null, "HTTP 404") + } + val repos = listOf(proxy, repository("central", "https://repo1.maven.org/maven2")) + + assertThat(policy.isEligible("com.example", "lib", "1.0", repos)).isTrue() + assertThat(requested).containsExactly( + "https://proxy.example/maven2/com/example/lib/1.0/lib-1.0.pom", + centralPom + ) + assertThat(warnings).singleElement().asString().doesNotContain("central-timestamp") + } + + @ParameterizedTest + @CsvSource(value = [ + "private,https://custom.example/maven2/", + "custom-proxy,https://custom.example/maven2/", + "central,https://custom.example/maven2/", + "central-proxy,file:/tmp/muzzle-repo/" + ]) + fun `custom and local repositories do not add a Central timestamp fallback`(id: String, url: String) { + val repo = repository(id, url) + val policy = policy { requestedUrl -> + assertThat(requestedUrl).startsWith(repo.url) + Timestamp(null, "missing or invalid Last-Modified header") + } + assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(repo))).isTrue() + assertThat(warnings).singleElement().asString().doesNotContain("central-timestamp") + } + + @Test + fun `does not fall back to an older timestamp for a release known to be too new`() { + val policy = policy { url -> + Timestamp(now) + } + + assertThat( + policy.isEligible( + "com.example", + "lib", + "1.0", + repositories + repository("extra", "https://extra.example") + ) + ).isFalse() + assertThat(requested).hasSize(1) + } + + @Test + fun `retains unverified versions with a warning without logging repository credentials`() { + val policy = policy { Timestamp(null, "HTTP 403") } + val repos = listOf(repository("private", "https://repo.example/secret-token/secure")) + + assertThat(policy.isEligible("com.example", "lib", "1.0", repos)).isTrue() + assertThat(warnings).singleElement().asString() + .contains("Muzzle retaining com.example:lib:1.0", "cannot verify publication age", "private: HTTP 403") + .doesNotContain("secret-token") + } + + @Test + fun `caches timestamps by repository and coordinate including unknown timestamps`() { + val policy = policy { url -> + Timestamp(null, "HTTP 404") + } + repeat(2) { eligible(policy, "1.0") } + eligible(policy, "1.1") + policy.isEligible("com.example", "lib", "1.0", listOf(repository("extra", "https://extra.example"))) + + assertThat(requested).hasSize(3) + } + + @Test + fun `a subsequent build reconsiders a deferred release using its own cutoff`() { + val publishedAt = now.minusSeconds(47 * 3600L) + val first = MuzzleDependencyAge(48, now, { Timestamp(publishedAt) }, warnings::add) + val second = MuzzleDependencyAge(48, now.plusSeconds(3600), { Timestamp(publishedAt) }, warnings::add) + + assertThat(eligible(first)).isFalse() + assertThat(eligible(second)).isTrue() + } + + @Test + fun `zero bypasses lookup even when no repositories are available`() { + val policy = MuzzleDependencyAge(0, now, { error("Unexpected timestamp lookup") }, warnings::add) + + assertThat(policy.isEligible("com.example", "lib", "1.0", emptyList())).isTrue() + assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(proxy))).isTrue() + assertThat(warnings).isEmpty() + } + + @ParameterizedTest + @ValueSource(ints = [0, 48, 72]) + fun `parses valid cooldown configuration`(hours: Int) { + assertThat(parseMinimumDependencyAgeHours(hours.toString())).isEqualTo(hours) + } + + @ParameterizedTest + @ValueSource(strings = ["", "-1", "abc", "1.5", "2147483648"]) + fun `rejects invalid configuration`(raw: String) { + assertThatThrownBy { parseMinimumDependencyAgeHours(raw) } + .isInstanceOf(GradleException::class.java).hasMessageContaining("non-negative integer") + } + + @Test + @Timeout(10) + fun `a stalled HTTP response times out without retries and retains later versions`() { + val requests = AtomicInteger() + val releaseResponse = CountDownLatch(1) + withRepository({ _ -> + requests.incrementAndGet() + releaseResponse.await(10, SECONDS) + }) { repo -> + try { + val policy = MuzzleDependencyAge(48, now, warn = warnings::add) + repeat(3) { version -> + assertThat(policy.isEligible("com.example", "lib", "1.$version", listOf(repo))).isTrue() + } + } finally { + releaseResponse.countDown() + } + } + assertThat(requests).hasValue(1) + assertThat(warnings).hasSize(3).allSatisfy { + assertThat(it).contains("SocketTimeoutException", "continuing without age verification") + } + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = [ + "200|Mon, 28 Sep 2026 12:00:00 GMT|", + "200||missing or invalid Last-Modified", + "200|not-a-date|missing or invalid Last-Modified", + "403||HTTP 403", "404||HTTP 404", "429||HTTP 429", "500||HTTP 500", "503||HTTP 503" + ]) + fun `HEAD requests read publication times and cache unavailable timestamps`( + status: Int, header: String?, reason: String? + ) { + val requests = AtomicInteger() + withRepository({ exchange -> + assertThat(exchange.requestMethod).isEqualTo("HEAD") + assertThat(exchange.requestURI.path).isEqualTo("/com/example/lib/1.0/lib-1.0.pom") + requests.incrementAndGet() + header?.let { exchange.responseHeaders.add("Last-Modified", it) } + exchange.sendResponseHeaders(status, -1) + }) { repo -> + val policy = MuzzleDependencyAge(48, now, warn = warnings::add) + repeat(2) { assertThat(policy.isEligible("com.example", "lib", "1.0", listOf(repo))).isTrue() } + } + assertThat(requests).hasValue(1) + if (reason == null) assertThat(warnings).isEmpty() + else assertThat(warnings).hasSize(2).allSatisfy { assertThat(it).contains(reason) } + } + + private fun policy(lookup: (String) -> Timestamp) = MuzzleDependencyAge(48, now, { url -> + requested.add(url) + lookup(url) + }, warnings::add) + + private fun eligible(policy: MuzzleDependencyAge, version: String = "1.0") = policy.isEligible("com.example", "lib", version, repositories) + + private fun repository(id: String, url: String) = RemoteRepository.Builder(id, "default", url).build() + + private fun withRepository(respond: (HttpExchange) -> Unit, check: (RemoteRepository) -> Unit) { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + try { + respond(exchange) + } finally { + exchange.close() + } + } + server.start() + try { + check(repository("fixture", "http://127.0.0.1:${server.address.port}")) + } finally { + server.stop(0) + } + } +} 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 003b172213f..38a176b4e00 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt @@ -382,7 +382,10 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { """ ) - val result = run(":dd-java-agent:instrumentation:demo:muzzle", "--stacktrace") + val result = run( + ":dd-java-agent:instrumentation:demo:muzzle", "--stacktrace", "--configuration-cache", + env = mapOf("MIN_DEPENDENCY_AGE_HOURS" to "48") + ) val muzzleTaskPath = ":dd-java-agent:instrumentation:demo:muzzle" val passDirectiveTaskPath = ":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk" val failDirectiveTaskPath = ":dd-java-agent:instrumentation:demo:muzzle-AssertFail-core-jdk" @@ -410,6 +413,14 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { assertThat(failDirectiveResult).isRegularFile() assertThat(passDirectiveResult.readText()).isEqualTo("PASSING") assertThat(failDirectiveResult.readText()).isEqualTo("PASSING") + assertThat(result.output).contains("BUILD SUCCESSFUL", "Configuration cache entry stored") + + val reused = run( + muzzleTaskPath, "--stacktrace", "--configuration-cache", + env = mapOf("MIN_DEPENDENCY_AGE_HOURS" to "48") + ) + assertThat(reused.output).contains("BUILD SUCCESSFUL", "Reusing configuration cache") + .doesNotContain("Muzzle deferring", "Muzzle retaining") } @Test @@ -755,6 +766,7 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { """.trimIndent() ) + check(pomFile.setLastModified(java.time.Instant.parse("2000-01-01T00:00:00Z").toEpochMilli())) writeProject( """ diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt index fbf887c4007..3050ab4d979 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt @@ -2,6 +2,7 @@ package datadog.gradle.plugin.muzzle import datadog.gradle.plugin.muzzle.MuzzleVersionUtils.RANGE_COUNT_LIMIT import org.eclipse.aether.artifact.DefaultArtifact +import org.eclipse.aether.repository.RemoteRepository import org.eclipse.aether.resolution.VersionRangeRequest import org.eclipse.aether.resolution.VersionRangeResult import org.eclipse.aether.util.version.GenericVersionScheme @@ -11,6 +12,7 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.ValueSource import org.assertj.core.api.Assertions.assertThat +import java.time.Instant import kotlin.random.Random class MuzzleVersionUtilsTest { @@ -145,6 +147,159 @@ class MuzzleVersionUtilsTest { assertThat(filtered.map { it.toString() }).containsExactlyInAnyOrder(*versionStrings) } + @Test + fun `checks age only after prerelease and skip exclusions`() { + val result = createVersionRangeResult("1.0.0", "1.1.0", "1.2.0-RC1", "1.3.0") + val checked = mutableListOf() + + val filtered = MuzzleVersionUtils.filterAndLimitVersions(result, setOf("1.1.0"), false) { + checked.add(it.toString()) + it.toString() != "1.3.0" + } + + assertThat(checked).containsExactly("1.0.0", "1.3.0") + assertThat(filtered.map { it.toString() }).containsExactly("1.0.0") + } + + @Test + fun `skips exact prerelease spelling before checking age`() { + val result = createVersionRangeResult("1.0.0", "2.0.0-SNAPSHOT") + val checked = mutableListOf() + + val filtered = MuzzleVersionUtils.filterAndLimitVersions(result, setOf("2.0.0-SNAPSHOT"), true) { + checked.add(it.toString()) + true + } + + assertThat(checked).containsExactly("1.0.0") + assertThat(filtered.map { it.toString() }).containsExactly("1.0.0") + } + + @Test + fun `selects the previous eligible patch before sampling a minor version`() { + val result = createVersionRangeResult("1.0.0", "1.0.1", "1.0.2") + + val filtered = MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), false) { + it.toString() != "1.0.2" + } + + assertThat(filtered.map { it.toString() }).containsExactlyInAnyOrder("1.0.0", "1.0.1") + } + + @Test + fun `preserves eligible bounds when original bounds are deferred`() { + val result = createVersionRangeResult(*(0..49).map { "1.$it.0" }.toTypedArray()) + + repeat(10) { + val filtered = MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), false) { + it.toString() != "1.0.0" && it.toString() != "1.49.0" + } + assertThat(filtered.map { it.toString() }) + .contains("1.1.0", "1.48.0").doesNotContain("1.0.0", "1.49.0") + assertThat(filtered).hasSizeLessThan(RANGE_COUNT_LIMIT) + } + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `large patch histories only look up timestamps for eligible boundaries`(timestampAvailable: Boolean) { + val now = Instant.parse("2026-10-01T00:00:00Z") + val proxy = RemoteRepository.Builder("central-proxy", "default", "https://proxy.example/maven2/").build() + val requests = mutableListOf() + val warnings = mutableListOf() + val age = MuzzleDependencyAge(48, now, { url -> + requests.add(url) + if (timestampAvailable && url.startsWith("https://repo1.maven.org/")) { + MuzzleDependencyAge.Timestamp(now.minusSeconds(72 * 3600L)) + } else { + MuzzleDependencyAge.Timestamp(null, "missing Last-Modified header") + } + }, warnings::add) + val versions = (0..49).flatMap { minor -> (0..19).map { patch -> "1.$minor.$patch" } } + + val filtered = MuzzleVersionUtils.filterAndLimitVersions(createVersionRangeResult(*versions.toTypedArray()), emptySet(), false) { + age.isEligible("com.example", "lib", it.toString(), listOf(proxy)) + } + + assertThat(filtered).hasSize(24) + assertThat(filtered.map { it.toString() }).contains("1.0.0", "1.49.19") + assertThat(requests).hasSize(48).doesNotHaveDuplicates() + assertThat(warnings).hasSize(if (timestampAvailable) 0 else 24) + } + + @Test + fun `eligible ranges preserve the master sample for the same random seed`() { + val result = createVersionRangeResult(*(0..49).flatMap { minor -> (0..19).map { patch -> "1.$minor.$patch" } }.toTypedArray()) + repeat(100) { seed -> + val expected = VersionSet(result.versions).lowAndHighForMajorMinor.shuffled(Random(seed)).toMutableList() + while (expected.size >= RANGE_COUNT_LIMIT) { + val removed = expected.removeAt(0) + if (removed == result.lowestVersion || removed == result.highestVersion) expected.add(removed) + } + + val actual = MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), false, random = Random(seed), isEligible = { true }) + + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected) + } + } + + @Test + fun `sampled fresh boundaries are backfilled and still produce 24 checks`() { + val result = createVersionRangeResult(*(0..49).flatMap { minor -> (0..19).map { patch -> "1.$minor.$patch" } }.toTypedArray()) + val checked = mutableListOf() + + val actual = MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), false, random = Random(17)) { + checked.add(it.toString()) + !it.toString().endsWith(".19") + } + + assertThat(actual).hasSize(24) + assertThat(actual.map { it.toString() }).contains("1.0.0", "1.49.18").noneMatch { it.endsWith(".19") } + assertThat(checked).doesNotHaveDuplicates().hasSizeLessThanOrEqualTo(48) + } + + @Test + fun `walks past fresh boundaries without checking interior patches`() { + val versions = (0..999).map { "1.0.$it" }.toTypedArray() + val checked = mutableListOf() + + val filtered = MuzzleVersionUtils.filterAndLimitVersions(createVersionRangeResult(*versions), emptySet(), false) { + checked.add(it.toString()) + it.toString() !in setOf("1.0.998", "1.0.999") + } + + assertThat(filtered.map { it.toString() }).containsExactlyInAnyOrder("1.0.0", "1.0.997") + assertThat(checked).containsExactlyInAnyOrder("1.0.0", "1.0.999", "1.0.998", "1.0.997") + } + + @Test + fun `retains the sole eligible interior patch and omits entirely deferred minor versions`() { + val result = createVersionRangeResult("1.0.0", "1.0.1", "1.0.2", "1.1.0", "1.1.1", "1.2.0") + val checked = mutableListOf() + + val filtered = MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), false) { + checked.add(it.toString()) + it.toString() == "1.0.1" + } + + assertThat(filtered.map { it.toString() }).containsExactly("1.0.1") + assertThat(checked).doesNotHaveDuplicates() + } + + @Test + fun `empty and entirely deferred ranges produce no versions`() { + for (versions in listOf(emptyArray(), arrayOf("1.0.0"), arrayOf("1.0.0", "1.0.1", "1.1.0"))) { + val checked = mutableListOf() + val filtered = MuzzleVersionUtils.filterAndLimitVersions(createVersionRangeResult(*versions), emptySet(), false) { + checked.add(it.toString()) + false + } + + assertThat(filtered).isEmpty() + assertThat(checked).containsExactlyInAnyOrder(*versions) + } + } + companion object { @JvmStatic fun includeSnapshotsCases() = listOf( diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt index 0d8c164e087..e1d912b63c3 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt @@ -2,6 +2,7 @@ package datadog.gradle.plugin.muzzle import org.eclipse.aether.version.Version import org.junit.jupiter.api.Test +import kotlin.random.Random import org.assertj.core.api.Assertions.assertThat class VersionSetTest { @@ -73,6 +74,30 @@ class VersionSetTest { } } + @Test + fun `eligible boundaries match eager filtering including equivalent repository spellings`() { + val random = Random(48) + val versions = (0..3).flatMap { minor -> (0..7).map { patch -> ver("1.$minor.$patch") } } + + listOf(ver("1.2.7-foo"), ver("1.2.7.foo"), ver("1.3.7-foo"), ver("1.3.7.foo")) + + repeat(100) { + val shuffled = versions.shuffled(random) + val eligible = shuffled.filter { random.nextBoolean() }.toSet() + val checked = mutableListOf() + val actual = VersionSet(shuffled).sampleEligibleBoundaries(24, Random(17)) { + checked.add(it) + it in eligible + } + val expected = VersionSet(shuffled.filter { it in eligible }).lowAndHighForMajorMinor.toMutableSet() + // Maven ordering may differ from parsed major/minor ordering; retain both range endpoints. + eligible.minOrNull()?.let(expected::add) + eligible.maxOrNull()?.let(expected::add) + + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected) + assertThat(checked).doesNotHaveDuplicates() + } + } + private fun ver(v: String): Version = TestVersion(v) private fun num(major: Int, minor: Int, micro: Int): Long { diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionServiceTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionServiceTest.kt new file mode 100644 index 00000000000..55164aeef37 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionServiceTest.kt @@ -0,0 +1,192 @@ +package datadog.gradle.plugin.muzzle.planner + +import datadog.gradle.plugin.MavenRepoFixture +import datadog.gradle.plugin.muzzle.MuzzleDependencyAge +import datadog.gradle.plugin.muzzle.MuzzleDirective +import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.eclipse.aether.RepositorySystem +import org.eclipse.aether.RepositorySystemSession +import org.eclipse.aether.repository.RemoteRepository +import org.eclipse.aether.resolution.VersionRangeRequest +import org.eclipse.aether.resolution.VersionRangeResult +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.io.File +import java.time.Instant + +class MavenMuzzleResolutionServiceTest { + @TempDir + lateinit var tempDir: File + + private val now = Instant.parse("2026-09-30T12:00:00Z") + private val warnings = mutableListOf() + private val system = MuzzleMavenRepoUtils.newRepositorySystem() + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `pass and fail directives exclude fresh versions but retain unverified versions`(assertPass: Boolean) { + val fixture = MavenRepoFixture(tempDir) + fixture.publishVersions("com.example", "lib", listOf("1.0.0", "1.1.0", "1.3.0")) + fixture.publishVersions("com.example", "lib", listOf("1.2.0"), publishedAt = now) + check(File(fixture.repoDir, "com/example/lib/1.3.0/lib-1.3.0.pom").delete()) + val directive = directive().apply { this.assertPass = assertPass } + + val artifacts = service(fixture).resolveArtifacts(directive) + + assertThat(artifacts.map { it.version }).containsExactlyInAnyOrder("1.0.0", "1.1.0", "1.3.0") + assertThat(warnings).anySatisfy { assertThat(it).contains("com.example:lib:1.2.0", "eligible at") } + .anySatisfy { assertThat(it).contains("com.example:lib:1.3.0", "cannot verify") } + } + + @Test + fun `all deferred direct versions fail with a clear cooldown error`() { + val fixture = MavenRepoFixture(tempDir) + fixture.publishVersions("com.example", "lib", listOf("1.0.0"), publishedAt = now) + + assertThatThrownBy { service(fixture).resolveArtifacts(directive()) } + .isInstanceOf(GradleException::class.java) + .hasMessageContaining("No eligible muzzle artifacts for com.example:lib") + .hasMessageContaining("48h publication cooldown") + .hasMessageContaining("-PmuzzleMinDependencyAgeHours=0") + .hasNoCause() + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `version exclusions fail with the same clear empty-selection error`(skipExactVersion: Boolean) { + val fixture = MavenRepoFixture(tempDir) + val version = if (skipExactVersion) "1.1.0" else "1.1.0-SNAPSHOT" + fixture.publishVersions("com.example", "lib", listOf(version)) + val directive = directive().apply { + if (skipExactVersion) skipVersions.add(version) + } + + assertThatThrownBy { service(fixture).resolveArtifacts(directive) } + .isInstanceOf(GradleException::class.java) + .hasMessageContaining("No eligible muzzle artifacts for com.example:lib") + .hasMessageContaining("after version exclusions and 48h publication cooldown") + .hasNoCause() + } + + @Test + fun `inverse selection defers fresh versions and retains extra repositories`() { + val central = MavenRepoFixture(File(tempDir, "central")) + central.publishVersions("com.example", "lib", listOf("1.0.0", "2.0.0")) + central.publishVersions("com.example", "lib", listOf("3.0.0"), publishedAt = now) + val extra = MavenRepoFixture(File(tempDir, "extra")) + extra.publishVersions("com.example", "lib", listOf("0.9.0")) + val directive = directive().apply { + assertInverse = true + extraRepository("extra", extra.repoUrl) + } + + val plans = MuzzleTaskPlanner(service(central)).plan(listOf(directive)) + + assertThat(plans.filter { it.directive.assertPass }.map { it.artifact?.version }) + .containsExactly("1.0.0") + assertThat(plans.filter { !it.directive.assertPass }.map { it.artifact?.version }) + .containsExactlyInAnyOrder("0.9.0", "2.0.0") + assertThat(warnings).singleElement().asString().contains("com.example:lib:3.0.0") + } + + @Test + fun `empty eligible inverse selection adds no checks`() { + val fixture = MavenRepoFixture(tempDir) + fixture.publishVersions("com.example", "lib", listOf("1.0.0")) + fixture.publishVersions("com.example", "lib", listOf("2.0.0"), publishedAt = now) + + val plans = MuzzleTaskPlanner(service(fixture)).plan(listOf(directive().apply { assertInverse = true })) + + assertThat(plans).hasSize(1) + assertThat(plans.single().artifact?.version).isEqualTo("1.0.0") + } + + @Test + fun `Central timestamp fallback defers fresh direct and inverse versions without changing discovery repositories`() { + val fixture = MavenRepoFixture(tempDir) + fixture.publishVersions("com.example", "lib", listOf("1.0.0", "1.1.0", "2.0.0", "3.0.0")) + val proxy = RemoteRepository.Builder("central-proxy", "default", "https://proxy.example/maven2/").build() + val local = RemoteRepository.Builder("fixture", "default", fixture.repoUrl).build() + val discoverySystem = object : RepositorySystem by system { + override fun resolveVersionRange(session: RepositorySystemSession, request: VersionRangeRequest): VersionRangeResult { + assertThat(request.repositories).containsExactly(proxy) + // Serve proxy metadata from local files without allowing discovery to contact Central. + return system.resolveVersionRange(session, VersionRangeRequest(request.artifact, listOf(local), request.requestContext)) + } + } + val requests = mutableListOf() + val age = MuzzleDependencyAge(48, now, { url -> + requests.add(url) + if (url.startsWith(proxy.url)) { + MuzzleDependencyAge.Timestamp(null, "missing or invalid Last-Modified header") + } else { + assertThat(url).startsWith("https://repo1.maven.org/maven2/") + val fresh = url.contains("/1.1.0/") || url.contains("/3.0.0/") + MuzzleDependencyAge.Timestamp(if (fresh) now else now.minusSeconds(72 * 3600L)) + } + }, warnings::add) + val service = MavenMuzzleResolutionService( + discoverySystem, + MuzzleMavenRepoUtils.newRepositorySystemSession(system), + age, + listOf(proxy) + ) + + val plans = MuzzleTaskPlanner(service).plan(listOf(directive().apply { assertInverse = true })) + + assertThat(plans.filter { it.directive.assertPass }.map { it.artifact?.version }).containsExactly("1.0.0") + assertThat(plans.filter { !it.directive.assertPass }.map { it.artifact?.version }).containsExactly("2.0.0") + assertThat(requests).hasSize(8) + assertThat(warnings).hasSize(2).allSatisfy { + assertThat(it).contains("Muzzle deferring").doesNotContain("cannot verify") + } + } + + @Test + fun `unverified direct and inverse versions remain planned`() { + val fixture = MavenRepoFixture(tempDir) + fixture.publishVersions("com.example", "lib", listOf("1.0.0", "2.0.0")) + for (version in listOf("1.0.0", "2.0.0")) { + check(File(fixture.repoDir, "com/example/lib/$version/lib-$version.pom").delete()) + } + + val plans = MuzzleTaskPlanner(service(fixture)).plan(listOf(directive().apply { assertInverse = true })) + + assertThat(plans.filter { it.directive.assertPass }.map { it.artifact?.version }) + .containsExactly("1.0.0") + assertThat(plans.filter { !it.directive.assertPass }.map { it.artifact?.version }) + .containsExactly("2.0.0") + assertThat(warnings).anySatisfy { assertThat(it).contains("Muzzle retaining com.example:lib:1.0.0", "cannot verify") } + .anySatisfy { assertThat(it).contains("Muzzle retaining com.example:lib:2.0.0", "cannot verify") } + } + + @Test + fun `zero override includes fresh and unverified versions`() { + val fixture = MavenRepoFixture(tempDir) + fixture.publishVersions("com.example", "lib", listOf("1.0.0", "1.1.0"), publishedAt = now) + check(File(fixture.repoDir, "com/example/lib/1.1.0/lib-1.1.0.pom").delete()) + + assertThat(service(fixture, 0).resolveArtifacts(directive()).map { it.version }) + .containsExactlyInAnyOrder("1.0.0", "1.1.0") + assertThat(warnings).isEmpty() + } + + private fun service(fixture: MavenRepoFixture, minimumAgeHours: Int = 48) = MavenMuzzleResolutionService( + system, + MuzzleMavenRepoUtils.newRepositorySystemSession(system), + MuzzleDependencyAge(minimumAgeHours, now, warn = warnings::add), + listOf(RemoteRepository.Builder("fixture", "default", fixture.repoUrl).build()) + ) + + private fun directive() = MuzzleDirective().apply { + group = "com.example" + module = "lib" + versions = "[1.0,2.0)" + assertPass = true + } +} diff --git a/docs/how_instrumentations_work.md b/docs/how_instrumentations_work.md index b81673073ec..8d68df2041b 100644 --- a/docs/how_instrumentations_work.md +++ b/docs/how_instrumentations_work.md @@ -118,6 +118,23 @@ To run muzzle on your instrumentation, run: ./gradlew :dd-java-agent:instrumentation:rediscala-1.8:muzzle ``` +Muzzle defers library versions published within the last **48 hours**, based on the POM's +`Last-Modified` timestamp. Override the cooldown with `-PmuzzleMinDependencyAgeHours=` +or `MIN_DEPENDENCY_AGE_HOURS`; the Gradle property takes precedence. A module can override both +with `muzzle { minimumDependencyAgeHours.set(hours) }`. Set the value to `0` to disable the cooldown. + +The cooldown applies to pass, fail, and inverse checks. Sampling retains the lowest and highest +eligible versions and backfills deferred boundaries with eligible releases. A declared range with +no eligible versions fails explicitly; an empty inverse selection adds no checks. Core JDK checks, +additional and transitive dependencies, and dependency range reports are unaffected. + +Timestamp lookups are cached within each planning pass and rechecked when validating a cached +configuration. HTTP lookups use HEAD requests with two-second connect/read timeouts and no retries; +a transport failure disables further timestamp requests to that repository for the planning pass. +If the Central proxy cannot provide a timestamp, muzzle tries the configured extra repositories, +then Maven Central for the timestamp only. If no timestamp is available, it warns and keeps the +version eligible so that repository failures do not silently remove compatibility checks. + > [!WARNING] > Muzzle does _not_ run tests. > It checks that the types and methods used by the instrumentation are present in particular versions of libraries.