Skip to content
Open
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
Expand Up @@ -23,22 +23,32 @@ import java.nio.file.Files
internal object MuzzleMavenRepoUtils {
private val log = Logging.getLogger(MuzzleMavenRepoUtils::class.java)
private val backoffDelaysSeconds = listOf(5L, 10L, 30L)
private const val MAVEN_CENTRAL_URL = "https://repo1.maven.org/maven2/"

/**
* Remote repositories used to query version ranges and fetch dependencies.
*
* When MAVEN_REPOSITORY_PROXY is set, the proxy *replaces* Maven Central instead of being
* prepended to it. Aether merges `maven-metadata.xml` from **every** repository of a
* [VersionRangeRequest] rather than stopping at the first hit, so keeping Central in the list
* would issue a request to repo1.maven.org for each muzzle directive even when the proxy
* already serves it -- exactly the Maven Central rate limiting the proxy exists to avoid.
* The trade-off is that a proxy miss now fails instead of silently falling back to Central.
*
* This intentionally reads the environment on each access: Gradle daemons can
* be reused across builds with different MAVEN_REPOSITORY_PROXY values.
*/
@JvmStatic
fun defaultMuzzleRepos(): List<RemoteRepository> {
val central = RemoteRepository.Builder("central", "default", "https://repo1.maven.org/maven2/").build()
val mavenProxyUrl = System.getenv("MAVEN_REPOSITORY_PROXY")
return if (mavenProxyUrl == null) {
listOf(central)
fun defaultMuzzleRepos(): List<RemoteRepository> =
defaultMuzzleRepos(System.getenv("MAVEN_REPOSITORY_PROXY"))

@JvmStatic
fun defaultMuzzleRepos(mavenProxyUrl: String?): List<RemoteRepository> {
val proxyUrl = mavenProxyUrl?.takeIf { it.isNotBlank() }
return if (proxyUrl == null) {
listOf(RemoteRepository.Builder("central", "default", MAVEN_CENTRAL_URL).build())
} else {
val proxy = RemoteRepository.Builder("central-proxy", "default", mavenProxyUrl).build()
listOf(proxy, central)
listOf(RemoteRepository.Builder("central-proxy", "default", proxyUrl).build())
}
}

Expand Down Expand Up @@ -173,8 +183,13 @@ internal object MuzzleMavenRepoUtils {
}
}

// Backoff only buys anything when a repository can be *transiently* unavailable. Local
// file: repositories (test fixtures, `mvn install` output) cannot, so sleeping 45s on them
// is pure waste.
val backoffApplies = enableBackoffRetries && rangeRequest.repositories.any { !it.url.startsWith("file:") }

var waitedSeconds = 0L
if (enableBackoffRetries) {
if (backoffApplies) {
for (delaySeconds in backoffDelaysSeconds) {
sleepBeforeBackoffRetry(delaySeconds, directiveArtifact)
waitedSeconds += delaySeconds
Expand All @@ -196,7 +211,7 @@ internal object MuzzleMavenRepoUtils {
failure,
attemptCount,
waitedSeconds,
enableBackoffRetries
backoffApplies
),
failure
)
Expand Down
26 changes: 24 additions & 2 deletions buildSrc/src/test/kotlin/datadog/gradle/plugin/GradleFixture.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ open class GradleFixture {

private val testKitDir: File get() = sharedTestKitDir

private val repositoryProxyInitScript: File
get() = File(testKitDir, "repository-proxy.init.gradle")

companion object {
// JVM-wide testkit dir shared across all GradleFixture instances. One daemon
// pool serves every test method, so kotlinc work on .gradle.kts scripts is
Expand All @@ -35,10 +38,17 @@ open class GradleFixture {
// fields.
private val sharedTestKitDir: File by lazy {
Files.createTempDirectory("gradle-testkit-").toFile().also { dir ->
// Register the cleanup hook before anything that can throw, so a failure below
// does not leak the temp dir.
Runtime.getRuntime().addShutdownHook(Thread {
stopDaemonsIn(dir)
dir.deleteRecursively()
})
val initScript = File(dir, "repository-proxy.init.gradle")
GradleFixture::class.java
.getResourceAsStream("/repository-proxy.init.gradle")
?.use { input -> initScript.outputStream().use(input::copyTo) }
?: error("Missing repository-proxy.init.gradle test resource")
}
}

Expand Down Expand Up @@ -110,6 +120,9 @@ open class GradleFixture {
* @param args Gradle task names and arguments
* @param expectFailure Whether the build is expected to fail
* @param env Environment variables to set (merged with system environment)
* @param unsetEnv Environment variables to remove from the system environment. Needed to
* exercise "no repository proxy configured" behaviour, since CI exports
* MAVEN_REPOSITORY_PROXY/GRADLE_PLUGIN_PROXY globally and [env] can only add.
* @param forwardOutput Forward the build's stdout/stderr to the test's output
* @param gradleProjectDir Override the project directory used by Gradle (useful for git worktree tests);
* defaults to the fixture's project directory.
Expand All @@ -119,6 +132,7 @@ open class GradleFixture {
vararg args: String,
expectFailure: Boolean = false,
env: Map<String, String> = emptyMap(),
unsetEnv: Set<String> = emptySet(),
forwardOutput: Boolean = false,
gradleProjectDir: File = projectDir,
): BuildResult {
Expand All @@ -127,8 +141,8 @@ open class GradleFixture {
.withPluginClasspath()
.withProjectDir(gradleProjectDir)
// Using withDebug prevents starting a daemon, but it doesn't work with withEnvironment
.withEnvironment(System.getenv() + env)
.withArguments(*args)
.withEnvironment(System.getenv() - unsetEnv + env)
.withArguments("--init-script", repositoryProxyInitScript.absolutePath, *args)
if (forwardOutput) {
runner.forwardOutput()
}
Expand Down Expand Up @@ -225,6 +239,14 @@ open class GradleFixture {
return builder.parse(xmlFile)
}

/**
* Creates a fake Maven repository under the project directory.
*
* Pass its [MavenRepoFixture.repoUrl] as `MAVEN_REPOSITORY_PROXY` to [run] to make it
* shadow Maven Central for the TestKit build.
*/
fun createMavenRepoFixture(): MavenRepoFixture = MavenRepoFixture(projectDir)

/**
* Returns a File handle under the project directory.
* Does not touch the filesystem.
Expand Down
147 changes: 147 additions & 0 deletions buildSrc/src/test/kotlin/datadog/gradle/plugin/GradleFixtureTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package datadog.gradle.plugin

import org.assertj.core.api.Assertions.assertThat
import org.gradle.testkit.runner.TaskOutcome.SUCCESS
import org.junit.jupiter.api.Test

class GradleFixtureTest : GradleFixture() {

private companion object {
const val MAVEN_CENTRAL = "https://repo.maven.apache.org/maven2"
const val PLUGIN_PORTAL = "https://plugins.gradle.org/m2"
}

@Test
fun `TestKit build routes Maven Central through configured proxy`() {
val proxyRepository = createMavenRepoFixture()
proxyRepository.publishVersions("com.example", "proxy-only", listOf("1.0.0"))
writeRootProject(
"""
plugins {
id("java")
}

repositories {
mavenCentral()
}

dependencies {
implementation("com.example:proxy-only:1.0.0")
}
"""
)
writeJavaSource("Example", "public class Example {}")

val result = run(
"compileJava",
env = mapOf("MAVEN_REPOSITORY_PROXY" to proxyRepository.repoUrl),
)

assertThat(result.task(":compileJava")?.outcome).isEqualTo(SUCCESS)
}

/**
* Contributors without access to the internal Depot mirror run with no proxy configured.
* The init script must then be completely inert. Assert on the repository URLs of every
* container it touches rather than resolving anything, so the check stays hermetic.
*/
@Test
fun `TestKit build leaves public repositories untouched when no proxy is configured`() {
writeSettings(
"""
import org.gradle.api.artifacts.repositories.MavenArtifactRepository

pluginManagement {
repositories {
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "no-proxy"

gradle.settingsEvaluated {
(pluginManagement.repositories + dependencyResolutionManagement.repositories)
.filterIsInstance<MavenArtifactRepository>()
.forEach { println("REPOSITORY=" + it.url) }
}
"""
)
writeRootProject(
"""
import org.gradle.api.artifacts.repositories.MavenArtifactRepository

buildscript {
repositories {
mavenCentral()
}
}

plugins {
id("java")
}

tasks.register("printRepositories") {
val urls = buildscript.repositories
.filterIsInstance<MavenArtifactRepository>()
.map { it.url.toString() }
doLast { urls.forEach { println("REPOSITORY=" + it) } }
}
"""
)

val result = run(
"printRepositories",
unsetEnv = setOf("MAVEN_REPOSITORY_PROXY", "GRADLE_PLUGIN_PROXY"),
)

val repositories = result.output.lines()
.filter { it.startsWith("REPOSITORY=") }
.map { it.removePrefix("REPOSITORY=") }
assertThat(repositories)
.withFailMessage("Expected repositories to be printed, output was:\n%s", result.output)
.isNotEmpty()
assertThat(repositories)
.withFailMessage("No proxy is configured, so nothing may be rewritten, got %s", repositories)
.allMatch { it.startsWith(MAVEN_CENTRAL) || it.startsWith(PLUGIN_PORTAL) }
assertThat(repositories).anyMatch { it.startsWith(MAVEN_CENTRAL) }
assertThat(repositories).anyMatch { it.startsWith(PLUGIN_PORTAL) }
}

@Test
fun `TestKit build routes settings-level Maven Central through configured proxy`() {
val proxyRepository = createMavenRepoFixture()
proxyRepository.publishVersions("com.example", "settings-proxy-only", listOf("1.0.0"))
writeSettings(
"""
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
"""
)
writeRootProject(
"""
plugins {
id("java")
}

dependencies {
implementation("com.example:settings-proxy-only:1.0.0")
}
"""
)
writeJavaSource("Example", "public class Example {}")

val result = run(
"compileJava",
env = mapOf("MAVEN_REPOSITORY_PROXY" to proxyRepository.repoUrl),
)

assertThat(result.task(":compileJava")?.outcome).isEqualTo(SUCCESS)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.junit.jupiter.params.provider.CsvSource
import java.io.File
import java.lang.reflect.Proxy
import java.util.concurrent.atomic.AtomicInteger
import kotlin.system.measureTimeMillis
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy

Expand Down Expand Up @@ -109,6 +110,53 @@ class MuzzleMavenRepoUtilsTest {
.hasMessageContaining("Backoff:\n disabled")
}

@Test
fun `defaultMuzzleRepos falls back to Maven Central when no proxy is configured`() {
assertThat(MuzzleMavenRepoUtils.defaultMuzzleRepos(null).map { it.id to it.url })
.containsExactly("central" to "https://repo1.maven.org/maven2/")
assertThat(MuzzleMavenRepoUtils.defaultMuzzleRepos(" ").map { it.id to it.url })
.containsExactly("central" to "https://repo1.maven.org/maven2/")
}

@Test
fun `defaultMuzzleRepos replaces Maven Central with the proxy so range queries do not hit it`() {
// Aether merges metadata from *every* repository of a range request (see
// `resolveVersionRange includes directive extra repositories`), so leaving Central in the
// list would query repo1.maven.org on every directive even when the proxy serves it.
val repos = MuzzleMavenRepoUtils.defaultMuzzleRepos("https://proxy.example/maven2/")

assertThat(repos.map { it.id to it.url })
.containsExactly("central-proxy" to "https://proxy.example/maven2/")
assertThat(repos.map { it.url }).noneMatch { it.contains("repo1.maven.org") }
}

@Test
fun `resolveVersionRange skips backoff when every repository is local`() {
val emptyRepo = RemoteRepository.Builder(
"empty",
"default",
File(tempDir, "empty").apply { mkdirs() }.toURI().toString()
).build()
val directive = MuzzleDirective().apply {
group = "com.example"
module = "nonexistent"
versions = "[1.0,)"
}

// enableBackoffRetries is left at its production default; a file: repository cannot be
// transiently unavailable, so the 5s/10s/30s sleeps must not run.
val elapsedMillis = measureTimeMillis {
assertThatThrownBy {
MuzzleMavenRepoUtils.resolveVersionRange(directive, system, newSession(), listOf(emptyRepo))
}.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("Backoff:\n disabled")
}

assertThat(elapsedMillis)
.withFailMessage("Expected no backoff sleeps for a file: repository, took %dms", elapsedMillis)
.isLessThan(5_000)
}

@Test
fun `resolveVersionRange failure includes thrown resolution failure details`() {
val directive = MuzzleDirective().apply {
Expand Down
Loading
Loading