From 321e2f2a6d054e994c06d2bf8e46e8104fa278c8 Mon Sep 17 00:00:00 2001 From: Alexey Kuznetsov Date: Tue, 28 Jul 2026 14:22:08 -0400 Subject: [PATCH 1/2] Cache Gradle distributions with MASS fallback --- .gitlab-ci.yml | 16 +- .gitlab/gitlab-utils.sh | 45 +++ build-logic/conventions/build.gradle.kts | 15 + .../datadog/buildlogic/mass/MassExtension.kt | 13 +- .../buildlogic/mass/MassExtensionTest.kt | 56 ++++ .../smoketest/GradleDistribution.kt | 17 + .../smoketest/GradleDistributionCache.kt | 235 ++++++++++++++ .../buildlogic/smoketest/NestedGradleBuild.kt | 52 +-- .../smoketest/GradleDistributionCacheTest.kt | 298 ++++++++++++++++++ .../instrumentation/cics-9.1/build.gradle | 21 +- dd-smoke-tests/gradle/build.gradle | 3 + .../smoketest/GradleDaemonSmokeTest.java | 50 +-- .../datadog/smoketest/GradleDistribution.java | 109 ++++++- .../smoketest/GradleDistributionTest.java | 51 +++ .../coverages.ftl | 2 +- .../events.ftl | 8 +- .../smoke/HelloPluginFunctionalTest.java | 7 +- dd-smoke-tests/rum/wildfly-15/build.gradle | 34 +- dd-smoke-tests/springboot-tomcat/build.gradle | 24 +- dd-smoke-tests/wildfly/build.gradle | 34 +- 20 files changed, 938 insertions(+), 152 deletions(-) create mode 100644 build-logic/conventions/src/test/kotlin/datadog/buildlogic/mass/MassExtensionTest.kt create mode 100644 build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt create mode 100644 build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt create mode 100644 dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistributionTest.java diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 08391601dfa..611c696155f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -251,11 +251,6 @@ default: # Apache Maven Wrapper supports MVNW_REPOURL for repository-manager downloads: # https://maven.apache.org/tools/wrapper/#Using_a_Maven_Repository_Manager - export MVNW_REPOURL=${MAVEN_REPOSITORY_PROXY%/} - # Route Gradle distribution download through MASS pull-through cache - - | - mass_read_host="${MASS_READ_URL#https://}" - mass_read_host="${mass_read_host%/}" - sed -i "/^distributionUrl=/ s|services.gradle.org|${mass_read_host}/internal/artifact/services.gradle.org|" gradle/wrapper/gradle-wrapper.properties - mkdir -p .mvn/caches # Redirect Spotless's Equo/Solstice P2 cache into the project tree so it is captured by the GitLab cache. # Solstice (https://github.com/equodev/equo-ide) defaults to ~/.m2/repository/dev/equo/p2-data, which is outside $CI_PROJECT_DIR. @@ -271,6 +266,8 @@ default: # GitLab's cache helper restores .gradle as root, but we run as non-root-user (uid 1001), # and Gradle does `chmod 700 .gradle` on startup which requires user ownership. - sudo chown -R 1001:1001 .gradle + # Bootstrap Gradle through MASS with upstream fallback. + - bootstrap_gradle_distribution after_script: - *cgroup_info - *container_info @@ -818,6 +815,7 @@ muzzle-dep-report: - dependency-lib unprotect: true before_script: + - source .gitlab/gitlab-utils.sh - git config --global --add safe.directory "$CI_PROJECT_DIR" # Akka token added to SSM from https://account.akka.io/token - export ORG_GRADLE_PROJECT_akkaRepositoryToken=$(aws ssm get-parameter --region us-east-1 --name ci.dd-trace-java.akka_repo_token --with-decryption --query "Parameter.Value" --out text) @@ -838,17 +836,13 @@ muzzle-dep-report: # Apache Maven Wrapper supports MVNW_REPOURL for repository-manager downloads: # https://maven.apache.org/tools/wrapper/#Using_a_Maven_Repository_Manager - export MVNW_REPOURL=${MAVEN_REPOSITORY_PROXY%/} - # Route Gradle distribution download through MASS pull-through cache - - | - mass_read_host="${MASS_READ_URL#https://}" - mass_read_host="${mass_read_host%/}" - sed -i "/^distributionUrl=/ s|services.gradle.org|${mass_read_host}/internal/artifact/services.gradle.org|" gradle/wrapper/gradle-wrapper.properties - *normalize_node_index - *prepare_test_env # Disable CDS in forked JVMs to avoid SIGSEGVs on Linux arm64. - export GRADLE_OPTS="-Dorg.gradle.jvmargs='-Xshare:off -Xms$GRADLE_MEMORY_MIN -Xmx$GRADLE_MEMORY_MAX -XX:ErrorFile=/tmp/hs_err_pid%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp -Djava.util.prefs.userRoot=/tmp/.java/.userPrefs-${CI_JOB_ID}' -Ddatadog.forkedMinHeapSize=128M -Ddatadog.forkedMaxHeapSize=1024M" - export GRADLE_ARGS="--build-cache --stacktrace --no-daemon --parallel --max-workers=$GRADLE_WORKERS" - - ./gradlew --version + # Bootstrap Gradle through MASS with upstream fallback. + - bootstrap_gradle_distribution script: - *gitlab_base_ref_params - ./gradlew $GRADLE_TARGET $GRADLE_PARAMS -PtestJvm=$testJvm -Pslot=$CI_NODE_INDEX/$CI_NODE_TOTAL $GRADLE_ARGS --continue diff --git a/.gitlab/gitlab-utils.sh b/.gitlab/gitlab-utils.sh index 59640da744b..e3ecdad22c3 100755 --- a/.gitlab/gitlab-utils.sh +++ b/.gitlab/gitlab-utils.sh @@ -16,6 +16,51 @@ function gitlab_section_end () { echo -e "section_end:`date +%s`:${section_title}\r\e[0K" } +# Bootstrap Gradle through MASS, retrying upstream because the wrapper supports only one URL. +# The pinned checksum applies to either source. Call after `.gradle` and the Gradle environment are +# prepared. +function bootstrap_gradle_distribution () { + local props="gradle/wrapper/gradle-wrapper.properties" + local upstream_props="/tmp/gradle-wrapper.upstream.properties" + local mass_read_host + + if [ -z "${MASS_READ_URL:-}" ]; then + ./gradlew --version + return + fi + + mass_read_host="${MASS_READ_URL#https://}" + mass_read_host="${mass_read_host%/}" + + # Derive upstream even if an earlier call already routed the URL through MASS. + sed "/^distributionUrl=/ s|${mass_read_host}/internal/artifact/||" "$props" > "$upstream_props" + + if grep -q "^distributionUrl=.*${mass_read_host}" "$props"; then + # Avoid nesting the MASS prefix. + echo "Gradle distribution is already routed through ${mass_read_host}" + else + # Redirect because GNU and BSD `sed -i` differ. + sed "/^distributionUrl=/ s|services.gradle.org|${mass_read_host}/internal/artifact/services.gradle.org|" "$props" > "$props.mass" && + mv "$props.mass" "$props" + fi + + if ./gradlew --version; then + rm -f "$upstream_props" + return + fi + + if ! grep -q "^distributionUrl=" "$upstream_props"; then + echo -e "${TEXT_RED}Gradle distribution bootstrap failed and no upstream distributionUrl could be derived from ${props} to fall back to${TEXT_CLEAR}" >&2 + return 1 + fi + + # Keep upstream: changing the URL changes the wrapper cache key and would download twice. + echo -e "${TEXT_YELLOW}MASS_FALLBACK gradle-distribution: ${MASS_READ_URL} could not serve the Gradle distribution, retrying via services.gradle.org${TEXT_CLEAR}" >&2 + cp "$upstream_props" "$props" + rm -f "$upstream_props" + ./gradlew --version +} + # A subset of ansi color/formatting codes https://misc.flogisoft.com/bash/tip_colors_and_formatting export TEXT_RED="\e[31m" export TEXT_GREEN="\e[32m" diff --git a/build-logic/conventions/build.gradle.kts b/build-logic/conventions/build.gradle.kts index a31634bb8ec..85482fb15a0 100644 --- a/build-logic/conventions/build.gradle.kts +++ b/build-logic/conventions/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `kotlin-dsl` + `jvm-test-suite` } java { @@ -12,3 +13,17 @@ kotlin { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) } } + +@Suppress("UnstableApiUsage") +testing { + suites { + named("test") { + useJUnitJupiter(libs.versions.junit5) + dependencies { + implementation(libs.junit.jupiter) + implementation(libs.junit.jupiter.engine) + implementation(libs.assertj.core) + } + } + } +} diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/mass/MassExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/mass/MassExtension.kt index 2dcb90a9c7b..3f3ca1d7f05 100644 --- a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/mass/MassExtension.kt +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/mass/MassExtension.kt @@ -11,9 +11,16 @@ constructor(objects: ObjectFactory, providers: ProviderFactory) { val readUrl: Property = objects.property(String::class.java).convention(providers.environmentVariable("MASS_READ_URL")) - fun artifactUrl(upstreamArtifactUrl: String): String { - val massReadUrl = readUrl.orNull ?: return "https://$upstreamArtifactUrl" + fun artifactUrl(upstreamArtifactUrl: String): String = artifactUrls(upstreamArtifactUrl).first() + + /** + * Ordered repository URLs: MASS, then upstream. Gradle advances only for missing artifacts, so + * this covers MASS cache misses, not connection failures. Without MASS, returns only upstream. + */ + fun artifactUrls(upstreamArtifactUrl: String): List { + val upstreamUrl = "https://$upstreamArtifactUrl" + val massReadUrl = readUrl.orNull?.takeIf { it.isNotBlank() } ?: return listOf(upstreamUrl) val baseUrl = if (massReadUrl.endsWith("/")) massReadUrl else "$massReadUrl/" - return "${baseUrl}internal/artifact/$upstreamArtifactUrl" + return listOf("${baseUrl}internal/artifact/$upstreamArtifactUrl", upstreamUrl) } } diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/mass/MassExtensionTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/mass/MassExtensionTest.kt new file mode 100644 index 00000000000..223a5ffbb25 --- /dev/null +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/mass/MassExtensionTest.kt @@ -0,0 +1,56 @@ +package datadog.buildlogic.mass + +import org.assertj.core.api.Assertions.assertThat +import org.gradle.kotlin.dsl.newInstance +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test + +class MassExtensionTest { + + private fun extension(massReadUrl: String?): MassExtension { + val project = ProjectBuilder.builder().build() + // Empty overrides the environment-backed convention. + return project.objects.newInstance().also { it.readUrl.set(massReadUrl ?: "") } + } + + @Test + fun `routes artifacts through MASS and keeps the upstream host as a second repository`() { + val mass = extension("https://mass.example") + + assertThat(mass.artifactUrls("dlcdn.apache.org")).containsExactly( + "https://mass.example/internal/artifact/dlcdn.apache.org", + "https://dlcdn.apache.org", + ) + assertThat(mass.artifactUrl("dlcdn.apache.org")) + .isEqualTo("https://mass.example/internal/artifact/dlcdn.apache.org") + } + + @Test + fun `tolerates a trailing slash on the MASS read url`() { + val mass = extension("https://mass.example/") + + assertThat(mass.artifactUrls("dlcdn.apache.org").first()) + .isEqualTo("https://mass.example/internal/artifact/dlcdn.apache.org") + } + + @Test + fun `declares the upstream host only once when MASS is not configured`() { + listOf(null, "", " ").forEach { unset -> + val mass = extension(unset) + + assertThat(mass.artifactUrls("dlcdn.apache.org")) + .containsExactly("https://dlcdn.apache.org") + assertThat(mass.artifactUrl("dlcdn.apache.org")).isEqualTo("https://dlcdn.apache.org") + } + } + + @Test + fun `keeps any path on the upstream artifact url`() { + val mass = extension("https://mass.example") + + assertThat(mass.artifactUrls("github.com/wildfly/wildfly/releases/download/")).containsExactly( + "https://mass.example/internal/artifact/github.com/wildfly/wildfly/releases/download/", + "https://github.com/wildfly/wildfly/releases/download/", + ) + } +} diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistribution.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistribution.kt index 9e5f3a0672d..7f0fccf7a26 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistribution.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistribution.kt @@ -4,9 +4,26 @@ import java.net.URI internal const val MASS_READ_URL_ENV = "MASS_READ_URL" +/** Public Gradle distribution source. */ +internal const val UPSTREAM_DISTRIBUTIONS_BASE_URL = "https://services.gradle.org/distributions" + internal fun gradleDistributionUri(massReadUrl: String, gradleVersion: String): URI { val baseUrl = if (massReadUrl.endsWith("/")) massReadUrl else "$massReadUrl/" return URI.create( "${baseUrl}internal/artifact/services.gradle.org/distributions/gradle-$gradleVersion-bin.zip", ) } + +internal fun upstreamGradleDistributionUri(gradleVersion: String): URI = + URI.create("$UPSTREAM_DISTRIBUTIONS_BASE_URL/gradle-$gradleVersion-bin.zip") + +/** Nested Gradle sources, preferring MASS and falling back upstream. */ +internal fun gradleDistributionUris(massReadUrl: String?, gradleVersion: String): List = + if (massReadUrl.isNullOrBlank()) { + listOf(upstreamGradleDistributionUri(gradleVersion)) + } else { + listOf( + gradleDistributionUri(massReadUrl, gradleVersion), + upstreamGradleDistributionUri(gradleVersion), + ) + } diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt new file mode 100644 index 00000000000..1adff7fb0ae --- /dev/null +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt @@ -0,0 +1,235 @@ +package datadog.buildlogic.smoketest + +import org.gradle.api.GradleException +import org.gradle.api.logging.Logger +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile +import java.net.HttpURLConnection +import java.net.URI +import java.net.URLConnection +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import java.util.zip.ZipInputStream + +/** Nested distributions, under `wrapper/` so CI restores them from its existing cache. */ +internal const val NESTED_DISTRIBUTIONS_DIR = "wrapper/nested-dists" + +/** Marks a complete install and records its source. */ +private const val COMPLETION_MARKER = ".ok" + +/** Gradle launcher jar pattern. */ +private val LAUNCHER_JAR = Regex("gradle-launcher-.*\\.jar") + +private const val CONNECT_TIMEOUT_MS = 30_000 +private const val READ_TIMEOUT_MS = 120_000 + +/** Serializes same-JVM callers before they acquire the cross-process file lock. */ +private val installMonitors = ConcurrentHashMap() + +/** Provisions [gradleVersion] once in [cacheDir], trying [distributionUris] in order. */ +internal fun provisionGradleDistribution( + cacheDir: File, + gradleVersion: String, + distributionUris: List, + logger: Logger, +): File { + require(distributionUris.isNotEmpty()) { + "At least one Gradle distribution URI is required to provision Gradle $gradleVersion" + } + val installDir = File(cacheDir, "gradle-$gradleVersion-bin") + val marker = File(installDir, COMPLETION_MARKER) + + installedDistributionRoot(installDir, gradleVersion, marker)?.let { return it } + + val monitor = installMonitors.computeIfAbsent(installDir.absolutePath) { Any() } + synchronized(monitor) { + installedDistributionRoot(installDir, gradleVersion, marker)?.let { return it } + if (!cacheDir.isDirectory && !cacheDir.mkdirs()) { + throw GradleException("Could not create Gradle distribution cache: ${cacheDir.absolutePath}") + } + RandomAccessFile(File(cacheDir, "gradle-$gradleVersion-bin.lock"), "rw").use { lockFile -> + lockFile.channel.lock().use { + // Recheck after waiting for the file lock. + installedDistributionRoot(installDir, gradleVersion, marker)?.let { return it } + install(installDir, marker, gradleVersion, distributionUris, logger) + return installedDistributionRoot(installDir, gradleVersion, marker) + ?: throw GradleException( + "Gradle $gradleVersion was unpacked into ${installDir.absolutePath} but no " + + "distribution root could be found in it", + ) + } + } + } +} + +private fun install( + installDir: File, + marker: File, + gradleVersion: String, + distributionUris: List, + logger: Logger, +) { + val failures = mutableListOf() + distributionUris.forEachIndexed { index, uri -> + val archive = File(installDir.parentFile, "gradle-$gradleVersion-bin.zip.part") + try { + if (index > 0) { + logger.lifecycle( + "MASS_FALLBACK gradle-nested-distribution: {} unavailable, provisioning Gradle {} from {}", + distributionUris[index - 1], + gradleVersion, + uri, + ) + } + installDir.deleteRecursively() + if (!installDir.mkdirs()) { + throw GradleException("Could not create ${installDir.absolutePath}") + } + logger.lifecycle("Provisioning Gradle {} for nested builds from {}", gradleVersion, uri) + download(uri, archive) + verifySha256(uri, archive, logger) + unzip(archive, installDir) + // Validate before marking the install complete. + val root = installedDistributionRoot(installDir, gradleVersion, marker = null) + ?: throw GradleException("does not contain a Gradle $gradleVersion distribution") + markExecutable(root) + marker.writeText(uri.toString()) + return + } catch (e: Exception) { + failures += "$uri -> ${e.message}" + logger.warn( + "Could not provision Gradle {} from {}: {}", + gradleVersion, + uri, + e.message, + ) + } finally { + archive.delete() + } + } + throw GradleException( + "Could not provision the Gradle $gradleVersion distribution from any source:" + + failures.joinToString(separator = "") { "\n - $it" }, + ) +} + +/** Returns the distribution root, requiring [marker] when provided. */ +private fun installedDistributionRoot( + installDir: File, + gradleVersion: String, + marker: File?, +): File? { + if (marker != null && !marker.isFile) { + return null + } + val expected = File(installDir, "gradle-$gradleVersion") + val root = if (expected.isDirectory) { + expected + } else { + // Tolerate a nonstandard archive root name. + installDir.listFiles()?.singleOrNull { it.isDirectory } + } + return root?.takeIf { isGradleDistribution(it) } +} + +/** Rejects partial cache restores by checking the launcher script and jar. */ +private fun isGradleDistribution(root: File): Boolean = + File(root, "bin/${NestedGradleBuild.gradleExecutableName()}").isFile && + File(root, "lib").listFiles()?.any { LAUNCHER_JAR.matches(it.name) } == true + +private fun download(uri: URI, target: File) { + val connection = openConnection(uri) + try { + connection.getInputStream().use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output, DEFAULT_BUFFER_SIZE) + } + } + } finally { + (connection as? HttpURLConnection)?.disconnect() + } +} + +/** Verifies a published SHA-256; missing checksums are allowed, mismatches trigger fallback. */ +private fun verifySha256(uri: URI, archive: File, logger: Logger) { + val expected = publishedSha256(uri, logger) ?: return + val actual = sha256(archive) + if (!expected.equals(actual, ignoreCase = true)) { + throw GradleException( + "SHA-256 mismatch for $uri (expected $expected, got $actual)", + ) + } +} + +private fun publishedSha256(uri: URI, logger: Logger): String? = + try { + val connection = openConnection(URI.create("$uri.sha256")) + try { + connection.getInputStream().use { it.readBytes() } + .toString(Charsets.UTF_8) + .trim() + .substringBefore(' ') + .takeIf { it.isNotEmpty() } + } finally { + (connection as? HttpURLConnection)?.disconnect() + } + } catch (e: Exception) { + logger.info("No SHA-256 published next to {} ({}), skipping verification", uri, e.message) + null + } + +private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) { + break + } + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { String.format("%02x", it) } +} + +private fun openConnection(uri: URI): URLConnection { + val connection = uri.toURL().openConnection() + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.connect() + if (connection is HttpURLConnection && connection.responseCode / 100 != 2) { + val status = connection.responseCode + connection.disconnect() + throw GradleException("Unexpected HTTP status $status") + } + return connection +} + +private fun unzip(archive: File, target: File) { + val canonicalTargetPath = target.canonicalPath + File.separator + ZipInputStream(archive.inputStream().buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + val destination = File(target, entry.name) + if (!destination.canonicalPath.startsWith(canonicalTargetPath)) { + throw GradleException("Zip entry escapes ${target.absolutePath}: ${entry.name}") + } + if (entry.isDirectory) { + destination.mkdirs() + } else { + destination.parentFile.mkdirs() + FileOutputStream(destination).use { output -> + zip.copyTo(output, DEFAULT_BUFFER_SIZE) + } + } + zip.closeEntry() + } + } +} + +/** Restores executable bits not exposed by [ZipInputStream]. */ +private fun markExecutable(distributionRoot: File) { + File(distributionRoot, "bin").listFiles()?.forEach { it.setExecutable(true, false) } +} diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt index 33da53475d1..b7754be9f0c 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt @@ -54,6 +54,11 @@ abstract class NestedGradleBuild @Inject constructor( gradleDistributionBaseUrl.convention( project.providers.environmentVariable(MASS_READ_URL_ENV), ) + gradleDistributionCacheDir.convention( + objects.directoryProperty().fileValue( + File(project.gradle.gradleUserHomeDir, NESTED_DISTRIBUTIONS_DIR), + ), + ) initScripts.convention(emptyList()) gradleProperties.convention(emptyMap()) javaLauncher.convention( @@ -79,13 +84,16 @@ abstract class NestedGradleBuild @Inject constructor( abstract val gradleVersion: Property /** - * Optional base URL for Gradle distribution downloads. CI sets this to MASS so nested builds - * download through the pull-through cache instead of directly from services.gradle.org. + * Optional MASS base URL for Gradle distributions. Upstream remains the fallback. */ @get:Input @get:Optional abstract val gradleDistributionBaseUrl: Property + /** Shared distribution cache; excluded as an input to avoid hashing unpacked files. */ + @get:Internal + abstract val gradleDistributionCacheDir: DirectoryProperty + @get:Input abstract val initScripts: ListProperty @@ -157,6 +165,15 @@ abstract class NestedGradleBuild @Inject constructor( val daemonJavaHome = javaLauncher.get().metadata.installationPath.asFile val gradleUserHomeDir = createGradleUserHome() val initScriptFiles = writeInitScripts() + val distributionRoot = provisionGradleDistribution( + cacheDir = gradleDistributionCacheDir.get().asFile, + gradleVersion = gradleVersion.get(), + distributionUris = gradleDistributionUris( + gradleDistributionBaseUrl.orNull, + gradleVersion.get(), + ), + logger = logger, + ) val args = buildList { initScriptFiles.forEach { script -> @@ -174,19 +191,11 @@ abstract class NestedGradleBuild @Inject constructor( addAll(buildArguments.get()) } + // Use the shared install instead of downloading into the temporary Gradle home. val connector = GradleConnector.newConnector() .forProjectDirectory(appDir) .useGradleUserHomeDir(gradleUserHomeDir) - .apply { - val distributionBaseUrl = gradleDistributionBaseUrl.orNull - if (distributionBaseUrl.isNullOrBlank()) { - useGradleVersion(gradleVersion.get()) - } else { - useDistribution( - gradleDistributionUri(distributionBaseUrl, gradleVersion.get()), - ) - } - } + .useInstallation(distributionRoot) val mergedEnv = System.getenv() + @@ -209,22 +218,22 @@ abstract class NestedGradleBuild @Inject constructor( .run() } } finally { - stopGradleDaemon(appDir, gradleUserHomeDir, daemonJavaHome, mergedEnv) + stopGradleDaemon(appDir, distributionRoot, daemonJavaHome, mergedEnv) deleteGradleUserHome(gradleUserHomeDir) } } private fun stopGradleDaemon( appDir: File, - gradleUserHomeDir: File, + distributionRoot: File, daemonJavaHome: File, environment: Map, ) { - val gradleExecutable = findGradleExecutable(gradleUserHomeDir) - if (gradleExecutable == null) { + val gradleExecutable = File(distributionRoot, "bin/${gradleExecutableName()}") + if (!gradleExecutable.isFile) { logger.warn( - "Could not find nested Gradle executable under {} to stop its daemon", - gradleUserHomeDir.absolutePath, + "Could not find nested Gradle executable at {} to stop its daemon", + gradleExecutable.absolutePath, ) return } @@ -276,13 +285,6 @@ abstract class NestedGradleBuild @Inject constructor( } } - private fun findGradleExecutable(gradleUserHomeDir: File): File? = - gradleUserHomeDir.walkTopDown().firstOrNull { file -> - file.isFile && - file.name == gradleExecutableName() && - file.parentFile?.name == "bin" - } - private fun createGradleUserHome(): File { val directory = temporaryDir.resolve("gradle-user-home") deleteGradleUserHome(directory) diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt new file mode 100644 index 00000000000..907544b5701 --- /dev/null +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt @@ -0,0 +1,298 @@ +package datadog.buildlogic.smoketest + +import com.sun.net.httpserver.HttpServer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.gradle.api.GradleException +import org.gradle.api.logging.Logging +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.net.InetSocketAddress +import java.net.URI +import java.nio.file.Path +import java.security.MessageDigest +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Tests the nested Gradle distribution cache with local archives. */ +class GradleDistributionCacheTest { + + @TempDir + lateinit var tempDir: Path + + private val logger = Logging.getLogger(GradleDistributionCacheTest::class.java) + + private val cacheDir get() = tempDir.resolve("cache").toFile() + + @Test + fun `provisions from the first source and returns the distribution root`() { + val distribution = writeDistributionArchive("primary.zip", VERSION) + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + + assertThat(root).isDirectory() + assertThat(root.name).isEqualTo("gradle-$VERSION") + assertThat(File(root, "bin/${launcherScriptName()}")).exists() + assertThat(File(root, "lib/gradle-launcher-$VERSION.jar")).exists() + } + + @Test + fun `falls back to the next source when the first is unreachable`() { + val missing = tempDir.resolve("absent.zip").toFile().toURI() + val fallback = writeDistributionArchive("fallback.zip", VERSION) + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(missing, fallback), logger) + + assertThat(File(root, "bin/${launcherScriptName()}")).exists() + assertThat(markerContent()).isEqualTo(fallback.toString()) + } + + @Test + fun `falls back when the first source serves an archive that fails checksum verification`() { + val corrupt = writeDistributionArchive("corrupt.zip", VERSION) + File(corrupt).resolveSibling("corrupt.zip.sha256").writeText("0".repeat(64)) + val fallback = writeDistributionArchive("good.zip", VERSION) + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(corrupt, fallback), logger) + + assertThat(markerContent()).isEqualTo(fallback.toString()) + assertThat(File(root, "bin/${launcherScriptName()}")).exists() + } + + @Test + fun `accepts a source whose published checksum matches`() { + val distribution = writeDistributionArchive("verified.zip", VERSION) + val archive = File(distribution) + archive.resolveSibling("verified.zip.sha256").writeText(sha256Hex(archive)) + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + + assertThat(markerContent()).isEqualTo(distribution.toString()) + assertThat(File(root, "bin/${launcherScriptName()}")).exists() + } + + @Test + fun `reuses an already provisioned distribution instead of downloading again`() { + val distribution = writeDistributionArchive("once.zip", VERSION) + + val first = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + // Remove the source to prove the second call uses the cache. + assertThat(File(distribution).delete()).isTrue() + val second = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + + assertThat(second).isEqualTo(first) + assertThat(File(second, "bin/${launcherScriptName()}")).exists() + } + + @Test + fun `re-provisions when a previous attempt left the distribution incomplete`() { + val distribution = writeDistributionArchive("resume.zip", VERSION) + val installDir = File(cacheDir, "gradle-$VERSION-bin") + // Simulate an interrupted unpack. + File(installDir, "gradle-$VERSION/bin").mkdirs() + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + + assertThat(File(root, "bin/${launcherScriptName()}")).exists() + assertThat(markerContent()).isEqualTo(distribution.toString()) + } + + @Test + fun `re-provisions when the cached distribution lost its launcher script`() { + val distribution = writeDistributionArchive("relaunch.zip", VERSION) + provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + // Simulate a cache restore with a marker but missing content. + assertThat(File(installedRoot(), "bin/${launcherScriptName()}").delete()).isTrue() + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + + assertThat(File(root, "bin/${launcherScriptName()}")).exists() + assertThat(File(root, "lib/gradle-launcher-$VERSION.jar")).exists() + } + + @Test + fun `re-provisions when the cached distribution lost its launcher jar`() { + val distribution = writeDistributionArchive("relib.zip", VERSION) + provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + assertThat(File(installedRoot(), "lib/gradle-launcher-$VERSION.jar").delete()).isTrue() + + val root = provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger) + + assertThat(File(root, "lib/gradle-launcher-$VERSION.jar")).exists() + } + + @Test + fun `falls back when a source serves an archive that is not a Gradle distribution`() { + val notADistribution = tempDir.resolve("bogus.zip").toFile() + ZipOutputStream(notADistribution.outputStream().buffered()).use { zip -> + zip.putNextEntry(ZipEntry("gradle-$VERSION/bin/${launcherScriptName()}")) + zip.write("#!/bin/sh\n".toByteArray()) + zip.closeEntry() + } + val fallback = writeDistributionArchive("real.zip", VERSION) + + val root = provisionGradleDistribution( + cacheDir, + VERSION, + listOf(notADistribution.toURI(), fallback), + logger, + ) + + assertThat(markerContent()).isEqualTo(fallback.toString()) + assertThat(File(root, "lib/gradle-launcher-$VERSION.jar")).exists() + } + + @Test + fun `reports every source that was tried when none can serve the distribution`() { + val first = tempDir.resolve("first-absent.zip").toFile().toURI() + val second = tempDir.resolve("second-absent.zip").toFile().toURI() + + assertThatThrownBy { + provisionGradleDistribution(cacheDir, VERSION, listOf(first, second), logger) + } + .isInstanceOf(GradleException::class.java) + .hasMessageContaining("first-absent.zip") + .hasMessageContaining("second-absent.zip") + } + + @Test + fun `concurrent callers provision once and all receive the same distribution`() { + // A counting source proves concurrent callers download only once. + val downloads = AtomicInteger() + val server = countingDistributionServer(downloads) + try { + val threads = 8 + val ready = CyclicBarrier(threads) + val roots = ConcurrentLinkedQueue() + val failures = ConcurrentLinkedQueue() + val workers = (1..threads).map { + Thread { + try { + ready.await(30, TimeUnit.SECONDS) + roots += provisionGradleDistribution(cacheDir, VERSION, listOf(server.uri), logger) + } catch (e: Throwable) { + failures += e + } + }.apply { start() } + } + workers.forEach { it.join(TimeUnit.MINUTES.toMillis(1)) } + + assertThat(failures).isEmpty() + assertThat(roots).hasSize(threads) + assertThat(roots.distinct()).hasSize(1) + assertThat(downloads).hasValue(1) + assertThat(File(roots.first(), "lib/gradle-launcher-$VERSION.jar")).exists() + } finally { + server.close() + } + } + + @Test + fun `requires at least one source`() { + assertThatThrownBy { + provisionGradleDistribution(cacheDir, VERSION, emptyList(), logger) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `rejects archives containing entries outside the target directory`() { + val escaping = tempDir.resolve("escaping.zip").toFile() + ZipOutputStream(escaping.outputStream().buffered()).use { zip -> + zip.putNextEntry(ZipEntry("../escaped.txt")) + zip.write("nope".toByteArray()) + zip.closeEntry() + } + + assertThatThrownBy { + provisionGradleDistribution(cacheDir, VERSION, listOf(escaping.toURI()), logger) + } + .isInstanceOf(GradleException::class.java) + .hasMessageContaining("escaped.txt") + assertThat(tempDir.resolve("escaped.txt").toFile()).doesNotExist() + } + + @Test + fun `distribution sources put MASS first and keep upstream as a fallback`() { + assertThat(gradleDistributionUris("https://mass.example", VERSION).map { it.toString() }) + .containsExactly( + "https://mass.example/internal/artifact/services.gradle.org/distributions/gradle-$VERSION-bin.zip", + "https://services.gradle.org/distributions/gradle-$VERSION-bin.zip", + ) + } + + @Test + fun `distribution sources fall back to upstream only when MASS is not configured`() { + val upstreamOnly = listOf("https://services.gradle.org/distributions/gradle-$VERSION-bin.zip") + + assertThat(gradleDistributionUris(null, VERSION).map { it.toString() }) + .isEqualTo(upstreamOnly) + assertThat(gradleDistributionUris("", VERSION).map { it.toString() }) + .isEqualTo(upstreamOnly) + assertThat(gradleDistributionUris(" ", VERSION).map { it.toString() }) + .isEqualTo(upstreamOnly) + } + + private fun markerContent(): String = + File(cacheDir, "gradle-$VERSION-bin/.ok").readText() + + /** Writes the minimal archive shape Gradle accepts. */ + private fun writeDistributionArchive(name: String, version: String): URI { + val archive = tempDir.resolve(name).toFile() + ZipOutputStream(archive.outputStream().buffered()).use { zip -> + zip.putNextEntry(ZipEntry("gradle-$version/")) + zip.closeEntry() + zip.putNextEntry(ZipEntry("gradle-$version/bin/${launcherScriptName()}")) + zip.write("#!/bin/sh\necho gradle $version\n".toByteArray()) + zip.closeEntry() + zip.putNextEntry(ZipEntry("gradle-$version/lib/gradle-launcher-$version.jar")) + zip.write("launcher-$name".toByteArray()) + zip.closeEntry() + } + return archive.toURI() + } + + private fun launcherScriptName(): String = NestedGradleBuild.gradleExecutableName() + + /** Serves an archive over HTTP and counts archive requests. */ + private fun countingDistributionServer(downloads: AtomicInteger): CountingServer { + val archive = File(writeDistributionArchive("counted.zip", VERSION)) + val bytes = archive.readBytes() + val checksum = sha256Hex(archive).toByteArray() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/gradle-$VERSION-bin.zip") { exchange -> + downloads.incrementAndGet() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + server.createContext("/gradle-$VERSION-bin.zip.sha256") { exchange -> + exchange.sendResponseHeaders(200, checksum.size.toLong()) + exchange.responseBody.use { it.write(checksum) } + } + server.start() + return CountingServer(server, VERSION) + } + + private class CountingServer(private val server: HttpServer, version: String) { + val uri: URI = + URI.create("http://127.0.0.1:${server.address.port}/gradle-$version-bin.zip") + + fun close() = server.stop(0) + } + + private fun installedRoot(): File = + File(cacheDir, "gradle-$VERSION-bin/gradle-$VERSION") + + private fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256").digest(file.readBytes()) + return digest.joinToString("") { String.format("%02x", it) } + } + + private companion object { + const val VERSION = "8.14.5" + } +} diff --git a/dd-java-agent/instrumentation/cics-9.1/build.gradle b/dd-java-agent/instrumentation/cics-9.1/build.gradle index 41f3d89ebbc..a909fa7795f 100644 --- a/dd-java-agent/instrumentation/cics-9.1/build.gradle +++ b/dd-java-agent/instrumentation/cics-9.1/build.gradle @@ -11,13 +11,20 @@ ext { } repositories { - ivy { - url = mass.artifactUrl('public.dhe.ibm.com/software/htp/cics/support/supportpacs/individual/') - patternLayout { - artifact '[module].[ext]' - } - metadataSources { - it.artifact() + // Try MASS first, then upstream on a cache miss. + mass.artifactUrls('public.dhe.ibm.com/software/htp/cics/support/supportpacs/individual/').eachWithIndex { repositoryUrl, index -> + ivy { + name = "cicsSdkDistribution$index" + url = repositoryUrl + content { + includeGroup cicsSdkName + } + patternLayout { + artifact '[module].[ext]' + } + metadataSources { + it.artifact() + } } } } diff --git a/dd-smoke-tests/gradle/build.gradle b/dd-smoke-tests/gradle/build.gradle index 76ebc34b6cb..a3dd389a61b 100644 --- a/dd-smoke-tests/gradle/build.gradle +++ b/dd-smoke-tests/gradle/build.gradle @@ -23,6 +23,9 @@ tasks.named("test", Test) { // Overriding the default timeout as Gradle smoke tests might run for a longer duration. timeout = Duration.of(25, ChronoUnit.MINUTES) + // Share distributions across test classes through the cached Gradle user home. + systemProperty 'datadog.smoketest.gradle.distributionCache', gradle.gradleUserHomeDir.absolutePath + if (project.hasProperty("mavenRepositoryProxy")) { // propagate proxy URL to tests, to then propagate it to nested Gradle builds environment "MAVEN_REPOSITORY_PROXY", project.property("mavenRepositoryProxy") diff --git a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java index 5b21058b906..6c92f669fe6 100644 --- a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java +++ b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java @@ -25,10 +25,6 @@ import org.gradle.testkit.runner.TaskOutcome; import org.gradle.tooling.internal.consumer.DefaultGradleConnector; import org.gradle.util.GradleVersion; -import org.gradle.wrapper.Download; -import org.gradle.wrapper.Install; -import org.gradle.wrapper.PathAssembler; -import org.gradle.wrapper.WrapperConfiguration; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.io.CleanupMode; @@ -42,9 +38,6 @@ class GradleDaemonSmokeTest extends AbstractGradleTest { private static final String TEST_SERVICE_NAME = "test-gradle-service"; - // Gradle's default timeout is 10s - private static final int GRADLE_DISTRIBUTION_NETWORK_TIMEOUT = 30_000; - // Cleanup is handled manually in stopGradleTestKitDaemons() instead of by JUnit: the TestKit // daemons may still hold file handles on this directory at class teardown, which would make // JUnit's recursive delete fail and turn the class into an executionError. @@ -148,7 +141,6 @@ void testRobolectric(String gradleVersion, String projectName, int expectedTrace givenGradleVersionIsSupportedByCurrentGradleTestKit(gradleVersion); givenGradleProjectFiles(projectName); givenGradleProjectProperties(); - ensureDependenciesDownloaded(gradleVersion); BuildResult buildResult = runGradleTests(gradleVersion, true, false); assertBuildSuccessful(buildResult); @@ -178,7 +170,6 @@ void testJunit4ClassOrdering( givenGradleVersionIsCompatibleWithCurrentJvm(gradleVersion); givenGradleProjectFiles(projectName); givenGradleProjectProperties(); - ensureDependenciesDownloaded(gradleVersion); mockBackend.givenKnownTests(true); for (TestFQN flakyTest : flakyTests) { @@ -241,7 +232,6 @@ private void runGradleTest( givenConfigurationCacheIsCompatibleWithCurrentPlatform(configurationCache); givenGradleProjectFiles(projectName); givenGradleProjectProperties(); - ensureDependenciesDownloaded(gradleVersion); mockBackend.givenFlakyRetries(flakyRetries); mockBackend.givenFlakyTest(":test", "datadog.smoke.TestFailed", "test_failed"); @@ -322,39 +312,6 @@ private BuildResult runGradleTests( return runGradle(gradleVersion, arguments, successExpected); } - /** - * Sometimes Gradle Test Kit fails because it cannot download the required Gradle distribution due - * to intermittent network issues. This method performs the download manually (if needed) with - * increased timeout (30s vs default 10s). Retry logic (3 retries) is already present in {@code - * org.gradle.wrapper.Install}. - */ - private void ensureDependenciesDownloaded(String gradleVersion) { - try { - org.gradle.wrapper.Logger logger = new org.gradle.wrapper.Logger(false); - Download download = - new Download( - logger, - "Gradle Tooling API", - GradleVersion.current().getVersion(), - GRADLE_DISTRIBUTION_NETWORK_TIMEOUT); - - java.io.File userHomeDir = testKitFolder.toFile(); - java.io.File projectDir = projectFolder.toFile(); - Install install = new Install(logger, download, new PathAssembler(userHomeDir, projectDir)); - - WrapperConfiguration configuration = new WrapperConfiguration(); - configuration.setDistribution(GradleDistribution.uriFor(gradleVersion)); - configuration.setNetworkTimeout(GRADLE_DISTRIBUTION_NETWORK_TIMEOUT); - - // This will download distribution (if not downloaded yet to userHomeDir) and verify its SHA. - install.createDist(configuration); - } catch (Exception e) { - System.out.println( - "Failed to install Gradle distribution, will proceed to run test kit hoping for the best: " - + e); - } - } - private BuildResult runGradle( String gradleVersion, List arguments, boolean successExpected) throws IOException { Map buildEnv = new HashMap<>(); @@ -362,9 +319,6 @@ private BuildResult runGradle( buildEnv.put("GRADLE_OPTS", ""); buildEnv.put("GRADLE_USER_HOME", testKitFolder.toString()); buildEnv.put("GRADLE_VERSION", gradleVersion); - buildEnv.put( - GradleDistribution.GRADLE_DISTRIBUTION_URL_ENV, - GradleDistribution.uriFor(gradleVersion).toString()); String mavenRepositoryProxy = System.getenv("MAVEN_REPOSITORY_PROXY"); if (mavenRepositoryProxy != null) { @@ -377,7 +331,9 @@ private BuildResult runGradle( GradleRunner.create() .withTestKitDir(testKitFolder.toFile()) .withProjectDir(projectFolder.toFile()), - gradleVersion) + gradleVersion, + projectFolder, + buildEnv) .withArguments(arguments) .withEnvironment(buildEnv) .forwardOutput(); diff --git a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistribution.java b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistribution.java index edaa526dbac..7eeeae0c957 100644 --- a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistribution.java +++ b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistribution.java @@ -1,35 +1,104 @@ package datadog.smoketest; +import java.io.File; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; -import java.util.regex.Matcher; import java.util.regex.Pattern; import org.gradle.testkit.runner.GradleRunner; import org.gradle.util.GradleVersion; import org.gradle.util.internal.DistributionLocator; +import org.gradle.wrapper.Download; +import org.gradle.wrapper.Install; +import org.gradle.wrapper.Logger; +import org.gradle.wrapper.PathAssembler; +import org.gradle.wrapper.WrapperConfiguration; final class GradleDistribution { - static final String GRADLE_DISTRIBUTION_URL_ENV = "GRADLE_DISTRIBUTION_URL"; + static final String GRADLE_INSTALLATION_DIR_ENV = "GRADLE_INSTALLATION_DIR"; private static final String MASS_READ_URL_ENV = "MASS_READ_URL"; - private static final Pattern DISTRIBUTION_URL_LINE = Pattern.compile("(?m)^distributionUrl=.*$"); + + /** Shared distribution cache; defaults to the Gradle user home. */ + private static final String DISTRIBUTION_CACHE_PROPERTY = + "datadog.smoketest.gradle.distributionCache"; + + // Gradle defaults to a 10s timeout and no retries. + private static final int NETWORK_TIMEOUT_MS = 30_000; + private static final int RETRIES = 2; + private static final int RETRY_BACK_OFF_MS = 1_000; private GradleDistribution() {} - static URI uriFor(String gradleVersion) { + /** Distribution sources, preferring MASS and falling back upstream. */ + static List urisFor(String gradleVersion) { + URI upstream = + new DistributionLocator().getDistributionFor(GradleVersion.version(gradleVersion)); String massReadUrl = System.getenv(MASS_READ_URL_ENV); if (massReadUrl == null || massReadUrl.trim().isEmpty()) { - return new DistributionLocator().getDistributionFor(GradleVersion.version(gradleVersion)); + return Collections.singletonList(upstream); } - return massUriFor(massReadUrl, gradleVersion); + List uris = new ArrayList<>(2); + uris.add(massUriFor(massReadUrl, gradleVersion)); + uris.add(upstream); + return uris; } - static GradleRunner withDistribution(GradleRunner runner, String gradleVersion) { + /** Preferred source for a distribution: MASS when configured, upstream otherwise. */ + static URI uriFor(String gradleVersion) { + return urisFor(gradleVersion).get(0); + } + + /** Installs from each source in order using Gradle's cache and locking. */ + static File install(String gradleVersion, Path projectFolder) { + Logger logger = new Logger(false); + Download download = + new Download( + logger, "Gradle Tooling API", GradleVersion.current().getVersion(), NETWORK_TIMEOUT_MS); + Install install = + new Install( + logger, download, new PathAssembler(distributionCache(), projectFolder.toFile())); + + for (URI uri : urisFor(gradleVersion)) { + WrapperConfiguration configuration = new WrapperConfiguration(); + configuration.setDistribution(uri); + configuration.setNetworkTimeout(NETWORK_TIMEOUT_MS); + configuration.setRetries(RETRIES); + configuration.setRetryBackOffMs(RETRY_BACK_OFF_MS); + try { + return install.createDist(configuration); + } catch (Exception e) { + System.out.println( + "MASS_FALLBACK gradle-testkit-distribution: could not install Gradle " + + gradleVersion + + " from " + + uri + + ": " + + e); + } + } + return null; + } + + /** Uses the shared install and exposes it to nested TestKit builds. */ + static GradleRunner withDistribution( + GradleRunner runner, + String gradleVersion, + Path projectFolder, + Map environment) { + File installation = install(gradleVersion, projectFolder); + if (installation != null) { + environment.put(GRADLE_INSTALLATION_DIR_ENV, installation.getAbsolutePath()); + return runner.withGradleInstallation(installation); + } String massReadUrl = System.getenv(MASS_READ_URL_ENV); if (massReadUrl == null || massReadUrl.trim().isEmpty()) { return runner.withGradleVersion(gradleVersion); @@ -48,16 +117,36 @@ static String uriPropertiesValueFor(String gradleVersion) { return uriFor(gradleVersion).toString().replace(":", "\\:"); } + /** Routes a fixture wrapper through MASS and enables retries. */ static void rewriteWrapperDistributionUrl(Path projectFolder, String gradleVersion) throws IOException { Path wrapperProperties = projectFolder.resolve("gradle/wrapper/gradle-wrapper.properties"); String contents = new String(Files.readAllBytes(wrapperProperties), StandardCharsets.UTF_8); - String replacement = "distributionUrl=" + uriPropertiesValueFor(gradleVersion); - String updated = - DISTRIBUTION_URL_LINE.matcher(contents).replaceFirst(Matcher.quoteReplacement(replacement)); + String updated = setProperty(contents, "distributionUrl", uriPropertiesValueFor(gradleVersion)); + updated = setProperty(updated, "networkTimeout", Integer.toString(NETWORK_TIMEOUT_MS)); + updated = setProperty(updated, "retries", Integer.toString(RETRIES)); + updated = setProperty(updated, "retryBackOffMs", Integer.toString(RETRY_BACK_OFF_MS)); Files.write(wrapperProperties, updated.getBytes(StandardCharsets.UTF_8)); } + private static String setProperty(String contents, String name, String value) { + String newline = contents.contains("\r\n") ? "\r\n" : "\n"; + Pattern line = Pattern.compile("(?m)^" + Pattern.quote(name) + "=.*(?:\\r?\\n|$)"); + String withoutProperty = line.matcher(contents).replaceAll(""); + if (!withoutProperty.isEmpty() && !withoutProperty.endsWith("\n")) { + withoutProperty += newline; + } + return withoutProperty + name + "=" + value + newline; + } + + private static File distributionCache() { + String configured = System.getProperty(DISTRIBUTION_CACHE_PROPERTY); + if (configured != null && !configured.trim().isEmpty()) { + return Paths.get(configured).toFile(); + } + return Paths.get(System.getProperty("user.home"), ".gradle").toFile(); + } + private static URI massUriFor(String massReadUrl, String gradleVersion) { String baseUrl = massReadUrl.endsWith("/") ? massReadUrl : massReadUrl + "/"; return URI.create( diff --git a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistributionTest.java b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistributionTest.java new file mode 100644 index 00000000000..21633483bd8 --- /dev/null +++ b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDistributionTest.java @@ -0,0 +1,51 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GradleDistributionTest { + + @TempDir Path projectFolder; + + @Test + void rewriteWrapperDistributionUrlReplacesGeneratedDefaults() throws IOException { + Path propertiesFile = projectFolder.resolve("gradle/wrapper/gradle-wrapper.properties"); + Files.createDirectories(propertiesFile.getParent()); + Files.write( + propertiesFile, + ("distributionBase=GRADLE_USER_HOME\n" + + "distributionUrl=https\\://old.example/gradle.zip\n" + + "networkTimeout=10000\n" + + "retries=0\n" + + "retryBackOffMs=500\n" + + "zipStoreBase=GRADLE_USER_HOME\n") + .getBytes(StandardCharsets.UTF_8)); + + GradleDistribution.rewriteWrapperDistributionUrl(projectFolder, "8.14.3"); + GradleDistribution.rewriteWrapperDistributionUrl(projectFolder, "8.14.3"); + + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(propertiesFile)) { + properties.load(input); + } + assertEquals(GradleDistribution.uriFor("8.14.3").toString(), properties.get("distributionUrl")); + assertEquals("30000", properties.get("networkTimeout")); + assertEquals("2", properties.get("retries")); + assertEquals("1000", properties.get("retryBackOffMs")); + + List lines = Files.readAllLines(propertiesFile, StandardCharsets.UTF_8); + assertEquals(1, lines.stream().filter(line -> line.startsWith("distributionUrl=")).count()); + assertEquals(1, lines.stream().filter(line -> line.startsWith("networkTimeout=")).count()); + assertEquals(1, lines.stream().filter(line -> line.startsWith("retries=")).count()); + assertEquals(1, lines.stream().filter(line -> line.startsWith("retryBackOffMs=")).count()); + } +} diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/coverages.ftl b/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/coverages.ftl index 6be477ce51c..172c09ee79e 100644 --- a/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/coverages.ftl +++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/coverages.ftl @@ -1,6 +1,6 @@ [ { "files" : [ { - "bitmap" : "AAAA8LF/8wE=", + "bitmap" : "AAAA+Ni/+Q==", "filename" : "src/test/java/datadog/smoke/HelloPluginFunctionalTest.java" } ], "span_id" : ${content_span_id_4}, diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/events.ftl b/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/events.ftl index 66ded4c6a4f..f82464a98ac 100644 --- a/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/events.ftl +++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/events.ftl @@ -216,8 +216,8 @@ "_dd.profiling.enabled" : 0, "_dd.trace_span_attribute_schema" : 0, "process_id" : ${content_metrics_process_id_2}, - "test.source.end" : 58, - "test.source.start" : 20 + "test.source.end" : 57, + "test.source.start" : 19 }, "name" : "junit5.test_suite", "resource" : "datadog.smoke.HelloPluginFunctionalTest", @@ -278,8 +278,8 @@ "_dd.profiling.enabled" : 0, "_dd.trace_span_attribute_schema" : 0, "process_id" : ${content_metrics_process_id_2}, - "test.source.end" : 49, - "test.source.start" : 36 + "test.source.end" : 48, + "test.source.start" : 35 }, "name" : "junit5.test", "parent_id" : ${content_parent_id}, diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/src/test/java/datadog/smoke/HelloPluginFunctionalTest.java b/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/src/test/java/datadog/smoke/HelloPluginFunctionalTest.java index e0cf8189e54..aac58854164 100644 --- a/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/src/test/java/datadog/smoke/HelloPluginFunctionalTest.java +++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-gradle-plugin-test/src/test/java/datadog/smoke/HelloPluginFunctionalTest.java @@ -5,7 +5,6 @@ import java.io.File; import java.io.IOException; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -33,13 +32,13 @@ void setUp() throws IOException { @Test void pluginPrintsHelloMessageOnGradle85() { - String gradleDistributionUrl = - requireNonNull(System.getenv("GRADLE_DISTRIBUTION_URL"), "GRADLE_DISTRIBUTION_URL"); + String gradleInstallationDir = + requireNonNull(System.getenv("GRADLE_INSTALLATION_DIR"), "GRADLE_INSTALLATION_DIR"); BuildResult result = GradleRunner.create() .withProjectDir(testProjectDir.toFile()) .withPluginClasspath() - .withGradleDistribution(URI.create(gradleDistributionUrl)) + .withGradleInstallation(new File(gradleInstallationDir)) .withArguments("hello", "--stacktrace") .withEnvironment(sanitizedGradleEnvironment(testProjectDir.resolve("gradle-user-home"))) .forwardOutput() diff --git a/dd-smoke-tests/rum/wildfly-15/build.gradle b/dd-smoke-tests/rum/wildfly-15/build.gradle index 2be56770cd8..14e2039d1a3 100644 --- a/dd-smoke-tests/rum/wildfly-15/build.gradle +++ b/dd-smoke-tests/rum/wildfly-15/build.gradle @@ -14,21 +14,25 @@ ext { } repositories { - ivy { - url = mass.artifactUrl('github.com/wildfly/wildfly/releases/download/') - // Restrict this repository to WildFly distribution artifacts only. - // Without this filter, Gradle may probe this host for unrelated dependencies - // (for example JUnit/Mockito), which makes the build flaky when the host is unreachable. - content { - includeGroup 'wildfly' - } - patternLayout { - // GitHub release assets are published under //wildfly-.zip - // while the dependency coordinates keep the "wildfly" group/module. - artifact '/[revision]/[module]-[revision].[ext]' - } - metadataSources { - it.artifact() + // Try MASS first, then upstream on a cache miss. + mass.artifactUrls('github.com/wildfly/wildfly/releases/download/').eachWithIndex { repositoryUrl, index -> + ivy { + name = "wildflyDistribution$index" + url = repositoryUrl + // Restrict this repository to WildFly distribution artifacts only. + // Without this filter, Gradle may probe this host for unrelated dependencies + // (for example JUnit/Mockito), which makes the build flaky when the host is unreachable. + content { + includeGroup 'wildfly' + } + patternLayout { + // GitHub release assets are published under //wildfly-.zip + // while the dependency coordinates keep the "wildfly" group/module. + artifact '/[revision]/[module]-[revision].[ext]' + } + metadataSources { + it.artifact() + } } } } diff --git a/dd-smoke-tests/springboot-tomcat/build.gradle b/dd-smoke-tests/springboot-tomcat/build.gradle index 5c506614081..b8bae2f40c6 100644 --- a/dd-smoke-tests/springboot-tomcat/build.gradle +++ b/dd-smoke-tests/springboot-tomcat/build.gradle @@ -13,16 +13,20 @@ def serverVersion = '9.0.117' def serverExtension = 'zip' repositories { - ivy { - url = mass.artifactUrl('dlcdn.apache.org') - content { - includeGroup serverName - } - patternLayout { - artifact '/[organisation]/[module]/v[revision]/bin/apache-[organisation]-[revision].[ext]' - } - metadataSources { - it.artifact() + // Try MASS first, then upstream on a cache miss. + mass.artifactUrls('dlcdn.apache.org').eachWithIndex { repositoryUrl, index -> + ivy { + name = "tomcatDistribution$index" + url = repositoryUrl + content { + includeGroup serverName + } + patternLayout { + artifact '/[organisation]/[module]/v[revision]/bin/apache-[organisation]-[revision].[ext]' + } + metadataSources { + it.artifact() + } } } } diff --git a/dd-smoke-tests/wildfly/build.gradle b/dd-smoke-tests/wildfly/build.gradle index cc8d48bf5a9..6737b2b5381 100644 --- a/dd-smoke-tests/wildfly/build.gradle +++ b/dd-smoke-tests/wildfly/build.gradle @@ -14,21 +14,25 @@ ext { } repositories { - ivy { - url = mass.artifactUrl('github.com/wildfly/wildfly/releases/download/') - // Restrict this repository to WildFly distribution artifacts only. - // Without this filter, Gradle may probe this host for unrelated dependencies - // (for example JUnit/Mockito), which makes the build flaky when the host is unreachable. - content { - includeGroup 'wildfly' - } - patternLayout { - // GitHub release assets are published under //wildfly-.zip - // while the dependency coordinates keep the "wildfly" group/module. - artifact '/[revision]/[module]-[revision].[ext]' - } - metadataSources { - it.artifact() + // Try MASS first, then upstream on a cache miss. + mass.artifactUrls('github.com/wildfly/wildfly/releases/download/').eachWithIndex { repositoryUrl, index -> + ivy { + name = "wildflyDistribution$index" + url = repositoryUrl + // Restrict this repository to WildFly distribution artifacts only. + // Without this filter, Gradle may probe this host for unrelated dependencies + // (for example JUnit/Mockito), which makes the build flaky when the host is unreachable. + content { + includeGroup 'wildfly' + } + patternLayout { + // GitHub release assets are published under //wildfly-.zip + // while the dependency coordinates keep the "wildfly" group/module. + artifact '/[revision]/[module]-[revision].[ext]' + } + metadataSources { + it.artifact() + } } } } From f39515c2106e311c15bc0d2d29a366a488843cc8 Mon Sep 17 00:00:00 2001 From: Alexey Kuznetsov Date: Tue, 28 Jul 2026 14:52:37 -0400 Subject: [PATCH 2/2] Fixed GitLab concurrency issue. --- .../smoketest/GradleDistributionCache.kt | 51 +++++++++++-------- .../smoketest/GradleDistributionCacheTest.kt | 39 ++++++++++++++ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt index 1adff7fb0ae..456bf641ead 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/GradleDistributionCache.kt @@ -8,8 +8,8 @@ import java.io.RandomAccessFile import java.net.HttpURLConnection import java.net.URI import java.net.URLConnection +import java.nio.channels.OverlappingFileLockException import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap import java.util.zip.ZipInputStream /** Nested distributions, under `wrapper/` so CI restores them from its existing cache. */ @@ -23,9 +23,7 @@ private val LAUNCHER_JAR = Regex("gradle-launcher-.*\\.jar") private const val CONNECT_TIMEOUT_MS = 30_000 private const val READ_TIMEOUT_MS = 120_000 - -/** Serializes same-JVM callers before they acquire the cross-process file lock. */ -private val installMonitors = ConcurrentHashMap() +private const val LOCK_RETRY_DELAY_MS = 100L /** Provisions [gradleVersion] once in [cacheDir], trying [distributionUris] in order. */ internal fun provisionGradleDistribution( @@ -42,22 +40,35 @@ internal fun provisionGradleDistribution( installedDistributionRoot(installDir, gradleVersion, marker)?.let { return it } - val monitor = installMonitors.computeIfAbsent(installDir.absolutePath) { Any() } - synchronized(monitor) { - installedDistributionRoot(installDir, gradleVersion, marker)?.let { return it } - if (!cacheDir.isDirectory && !cacheDir.mkdirs()) { - throw GradleException("Could not create Gradle distribution cache: ${cacheDir.absolutePath}") - } - RandomAccessFile(File(cacheDir, "gradle-$gradleVersion-bin.lock"), "rw").use { lockFile -> - lockFile.channel.lock().use { - // Recheck after waiting for the file lock. - installedDistributionRoot(installDir, gradleVersion, marker)?.let { return it } - install(installDir, marker, gradleVersion, distributionUris, logger) - return installedDistributionRoot(installDir, gradleVersion, marker) - ?: throw GradleException( - "Gradle $gradleVersion was unpacked into ${installDir.absolutePath} but no " + - "distribution root could be found in it", - ) + if (!cacheDir.isDirectory && !cacheDir.mkdirs() && !cacheDir.isDirectory) { + throw GradleException("Could not create Gradle distribution cache: ${cacheDir.absolutePath}") + } + return withDistributionFileLock(File(cacheDir, "gradle-$gradleVersion-bin.lock")) { + // Recheck after waiting for the file lock. + installedDistributionRoot(installDir, gradleVersion, marker) + ?.let { return@withDistributionFileLock it } + install(installDir, marker, gradleVersion, distributionUris, logger) + installedDistributionRoot(installDir, gradleVersion, marker) + ?: throw GradleException( + "Gradle $gradleVersion was unpacked into ${installDir.absolutePath} but no " + + "distribution root could be found in it", + ) + } +} + +private fun withDistributionFileLock(lockPath: File, action: () -> T): T { + RandomAccessFile(lockPath, "rw").use { lockFile -> + while (true) { + val lock = + try { + lockFile.channel.lock() + } catch (_: OverlappingFileLockException) { + // Another caller in this JVM holds the lock. + Thread.sleep(LOCK_RETRY_DELAY_MS) + continue + } + lock.use { + return action() } } } diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt index 907544b5701..4d31c0d5c37 100644 --- a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/GradleDistributionCacheTest.kt @@ -8,11 +8,14 @@ import org.gradle.api.logging.Logging import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File +import java.io.RandomAccessFile import java.net.InetSocketAddress import java.net.URI import java.nio.file.Path import java.security.MessageDigest +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch import java.util.concurrent.CyclicBarrier import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -192,6 +195,42 @@ class GradleDistributionCacheTest { } } + @Test + fun `waits when the distribution lock is already held in this JVM`() { + val distribution = writeDistributionArchive("overlapping-lock.zip", VERSION) + assertThat(cacheDir.mkdirs()).isTrue() + val started = CountDownLatch(1) + val result = CompletableFuture() + val worker = Thread { + started.countDown() + try { + result.complete( + provisionGradleDistribution(cacheDir, VERSION, listOf(distribution), logger), + ) + } catch (e: Throwable) { + result.completeExceptionally(e) + } + } + + val root = + try { + RandomAccessFile(File(cacheDir, "gradle-$VERSION-bin.lock"), "rw").use { lockFile -> + lockFile.channel.lock().use { + worker.start() + assertThat(started.await(10, TimeUnit.SECONDS)).isTrue() + Thread.sleep(200) + assertThat(result.isDone).isFalse() + } + } + result.get(30, TimeUnit.SECONDS) + } finally { + worker.join(TimeUnit.SECONDS.toMillis(30)) + } + + assertThat(root).isDirectory() + assertThat(File(root, "lib/gradle-launcher-$VERSION.jar")).exists() + } + @Test fun `requires at least one source`() { assertThatThrownBy {