Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String, Timestamp>()
private val unavailableRepositories = mutableMapOf<String, Timestamp>()

init {
require(minimumAgeHours >= 0) { "Muzzle minimum dependency age must be non-negative" }
}

fun isEligible(group: String, module: String, version: String, repositories: List<RemoteRepository>): Boolean {
if (minimumAgeHours == 0) return true

val coordinate = "$group:$module:$version"
val pomPath = "${group.replace('.', '/')}/$module/$version/$module-$version.pom"
val failures = mutableListOf<String>()
// 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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>().convention(
providers.gradleProperty("muzzleMinDependencyAgeHours")
.orElse(providers.environmentVariable("MIN_DEPENDENCY_AGE_HOURS"))
.map(::parseMinimumDependencyAgeHours)
.orElse(48)
)

val directives: MutableList<MuzzleDirective> = ArrayList()
private val additionalRepositories: MutableList<Triple<String, String, String>> = ArrayList()

Expand Down Expand Up @@ -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"
)
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ internal object MuzzleMavenRepoUtils {
system: RepositorySystem,
session: RepositorySystemSession,
defaultRepos: List<RemoteRepository> = defaultMuzzleRepos(),
random: Random = Random.Default
random: Random = Random.Default,
isEligible: ((Version) -> Boolean)? = null
): Set<MuzzleDirective> {
val allVersionsArtifact = DefaultArtifact(
muzzleDirective.group,
Expand Down Expand Up @@ -117,7 +118,8 @@ internal object MuzzleMavenRepoUtils {
allRangeResult,
muzzleDirective.skipVersions,
muzzleDirective.includeSnapshots,
random
random,
isEligible
).map { version ->
muzzleDirective.inverse(version.toString())
}.toSet()
Expand Down Expand Up @@ -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<Artifact> {
val versions = MuzzleVersionUtils.filterAndLimitVersions(
rangeResult,
muzzleDirective.skipVersions,
muzzleDirective.includeSnapshots,
random
random,
isEligible
)
val allVersionArtifacts = versions.map { version ->
DefaultArtifact(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand All @@ -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<MuzzleExtension>("muzzle", project.objects)
project.extensions.create<MuzzleExtension>("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)
Expand Down Expand Up @@ -159,37 +161,49 @@ class MuzzlePlugin @Inject constructor(private val buildFeatures: BuildFeatures)
}

private fun muzzlePlans(project: Project, directives: List<MuzzleDirective>): List<MuzzlePlannedVersion> {
val minimumAgeHours = project.extensions.getByType<MuzzleExtension>().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)
}
}
}

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<List<List<Triple<String, String, String>>>, MuzzleTaskPlanner>())
properties.set(key, SharedMuzzlePlanning())
}
@Suppress("UNCHECKED_CAST")
val planners = properties.get(key) as MutableMap<List<List<Triple<String, String, String>>>, 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<MuzzleExtension>().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<List<List<Triple<String, String, String>>>, RepositorySystemSession>()
private val ages = mutableMapOf<Int, MuzzleDependencyAge>()
private val planners = mutableMapOf<Pair<List<List<Triple<String, String, String>>>, Int>, MuzzleTaskPlanner>()

fun planner(repositories: List<List<Triple<String, String, String>>>, 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ internal object MuzzleVersionUtils {
result: VersionRangeResult,
skipVersions: Set<String>,
includeSnapshots: Boolean,
random: Random = Random.Default
random: Random = Random.Default,
isEligible: ((Version) -> Boolean)? = null
): Set<Version> {
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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Version>) {
private val sortedVersions: SortedSet<ParsedVersion> = sortedSetOf()

init {
versions.forEach { sortedVersions.add(ParsedVersion(it)) }
}
class VersionSet(private val versions: Collection<Version>) {
private val parsedVersions = versions.map(::ParsedVersion)
private val sortedVersions: SortedSet<ParsedVersion> = parsedVersions.toSortedSet()

val lowAndHighForMajorMinor: List<Version>
get() {
Expand All @@ -30,6 +28,41 @@ class VersionSet(versions: Collection<Version>) {
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<Version> {
val eligibility = mutableMapOf<Version, Boolean>()
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<ParsedVersion> {
companion object {
private val dotPattern = Regex("\\.")
Expand Down
Loading
Loading