diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 99cb7ad562c..1c57c6be244 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -495,6 +495,33 @@ test_published_artifacts: paths: - ./check_reports +validate_build: + extends: .gradle_build + stage: tests + needs: [ build ] + variables: + CACHE_TYPE: "lib" + script: + # Preserve the `build` job artifacts before Gradle rebuilds and overwrites them under + # workspace/**/build/libs, so jardiff can compare the rebuilt jars against them. + - mkdir -p reference-artifacts + - cp workspace/dd-java-agent/build/libs/*.jar reference-artifacts/ + - cp workspace/dd-trace-api/build/libs/*.jar reference-artifacts/ + - cp workspace/dd-trace-ot/build/libs/*.jar reference-artifacts/ + - ./gradlew --version + # This will run the shadowJar task and exercise the build cache, allowing to identify build-cache issues + - ./gradlew compareToReferenceJar -PjardiffReferenceDir="$CI_PROJECT_DIR/reference-artifacts" -PskipTests $GRADLE_ARGS + after_script: + - source .gitlab/gitlab-utils.sh + - gitlab_section_start "collect-reports" "Collecting reports" + - .gitlab/collect_reports.sh --destination ./check_reports + - gitlab_section_end "collect-reports" + artifacts: + when: always + paths: + - ./check_reports + - '.gradle/daemon/*/*.out.log' + .check_job: extends: .gradle_build needs: [ build ] diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 441a90dcba0..991ca899596 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -73,6 +73,11 @@ gradlePlugin { id = "dd-trace-java.sca-enrichments" implementationClass = "datadog.gradle.plugin.sca.ScaEnrichmentsPlugin" } + + create("jardiff-plugin") { + id = "dd-trace-java.jardiff" + implementationClass = "datadog.gradle.plugin.jardiff.JardiffPlugin" + } } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt new file mode 100644 index 00000000000..4be130aabe0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt @@ -0,0 +1,84 @@ +package datadog.gradle.plugin.jardiff + +import java.io.File + +/** + * Pure, Gradle-free helpers for driving the [jardiff](https://github.com/bric3/jardiff) CLI. + * + * Kept separate from [JardiffTask] so the argument construction and exit-code interpretation can + * be unit-tested without spinning up a Gradle build or resolving the jardiff jar. + */ +object JardiffComparison { + /** Outcome of a jardiff run, derived from its process exit value. */ + enum class Outcome { + /** Exit 0 — the two jars are identical (for the selected include/exclude set). */ + IDENTICAL, + + /** Exit 1 — jardiff reported differences (behaves like `diff(1)` under `--exit-code`). */ + DIFFERENT, + + /** Any other exit value — jardiff itself failed (bad arguments, unreadable jar, ...). */ + ERROR, + } + + /** + * Builds the jardiff argument list comparing [reference] (left) against [candidate] (right). + * + * [mode] selects the output mode (e.g. `--stat` for a `git diff --stat`-like summary, `--status`, + * or blank for the default full diff). `--exit-code` is always added. + * The [JardiffTask] relies on the process exit value. + * [includes]/[excludes] are comma-joined glob patterns (empty means comparing every entry), and + * [additionalOptions] are passed through verbatim, right before the two jars. + */ + fun buildArguments( + reference: File, + candidate: File, + mode: String, + includes: List = emptyList(), + excludes: List = emptyList(), + additionalOptions: List = emptyList(), + ): List = buildList { + if (mode.isNotBlank()) { + add(mode) + } + add("--exit-code") + add("--color=never") + if (includes.isNotEmpty()) { + add("--include=" + includes.joinToString(",")) + } + if (excludes.isNotEmpty()) { + add("--exclude=" + excludes.joinToString(",")) + } + addAll(additionalOptions) + // jardiff positional arguments: . + // The reference (the artifact validated by the build job) is the left/baseline side, + // the freshly built candidate is the right side. + add(reference.absolutePath) + add(candidate.absolutePath) + } + + /** Maps a jardiff process exit value to an [Outcome]. */ + fun outcomeOf(exitValue: Int): Outcome = when (exitValue) { + 0 -> Outcome.IDENTICAL + 1 -> Outcome.DIFFERENT + else -> Outcome.ERROR + } + + /** + * Renders the equivalent `java -cp … ` shell command, so the comparison + * can be copy-pasted and reproduced outside Gradle. Tokens containing shell-significant + * characters (spaces, globs, commas, ...) are single-quoted. + */ + fun shellCommandLine(classpath: Iterable, mainClass: String, arguments: List): String { + val classpathValue = classpath.joinToString(File.pathSeparator) { it.absolutePath } + return (listOf("java", "-cp", classpathValue, mainClass) + arguments) + .joinToString(" ", transform = ::shellQuote) + } + + private fun shellQuote(token: String): String = + if (token.isNotEmpty() && token.all { it.isLetterOrDigit() || it in "/._-=:" }) { + token + } else { + "'" + token.replace("'", "'\\''") + "'" + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt new file mode 100644 index 00000000000..ea30caede98 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt @@ -0,0 +1,43 @@ +package datadog.gradle.plugin.jardiff + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** Configuration for the [JardiffPlugin]. */ +interface JardiffExtension { + /** + * Maven coordinate of the jardiff CLI resolved to run the comparison. + * Defaults to [JardiffPlugin.DEFAULT_TOOL_COORDINATE]. + */ + val toolCoordinate: Property + + /** + * Fully qualified main class of the jardiff CLI. + * Defaults to [JardiffPlugin.DEFAULT_MAIN_CLASS]. + */ + val mainClass: Property + + /** + * jardiff output mode flag, e.g. `--stat` or `--status`; blank selects the default full diff. + * Defaults to [JardiffPlugin.DEFAULT_MODE]. + */ + val mode: Property + + /** + * Extra jardiff options passed verbatim, right before the two jars (e.g. `--ignore-member-order` + * or `--class-text-producer=javap`). Empty by default. + */ + val additionalOptions: ListProperty + + /** + * Directory receiving jardiff reports. Defaults to `build/reports/jardiff` in the target project. + */ + val reportDir: DirectoryProperty + + /** + * When true, tolerate mismatching candidate and reference jar hashes if jardiff reports no + * differences. Defaults to false. + */ + val ignoreHashCheck: Property +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt new file mode 100644 index 00000000000..d73fdd7a4c8 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt @@ -0,0 +1,100 @@ +package datadog.gradle.plugin.jardiff + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.bundling.AbstractArchiveTask +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register + +/** + * Registers the `compareToReferenceJar` task ([JardiffTask]) and wires its reusable inputs: + * - the jardiff CLI classpath (resolved from [JardiffExtension.toolCoordinate]), + * - the candidate archive — the module's main publishable jar (the shadow jar when the shadow + * plugin is applied, otherwise the plain jar), + * - the reference jar, resolved from the `-PjardiffReferenceDir` project property by matching the + * candidate's file name inside that directory. + * + * Apply it with `id("dd-trace-java.jardiff")`. + */ +class JardiffPlugin : Plugin { + override fun apply(project: Project) { + val extension = project.extensions.create("jardiff") + extension.toolCoordinate.convention(DEFAULT_TOOL_COORDINATE) + extension.mainClass.convention(DEFAULT_MAIN_CLASS) + extension.mode.convention(DEFAULT_MODE) + extension.additionalOptions.convention(emptyList()) + extension.reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff")) + extension.ignoreHashCheck.convention(false) + + // Use a detached configuration (created here, resolved only when the task runs) + // + // This keeps the jardiff artifact out of `lockAllConfigurations()` dependency locking. + // The dependency is added lazily, so overriding `jardiff.toolCoordinate` still takes effect. + // The `@jar` requests the artifact only, because jardiff ships a self-contained "fat" CLI jar + // under a non-default Gradle Module Metadata variant, the default resolution misses it. + // Appending `@jar` ignores metadata and fetches that jar. + val toolClasspath = project.configurations.detachedConfiguration().apply { + dependencies.addLater( + extension.toolCoordinate.map { coordinate -> + val artifactOnly = if ('@' in coordinate) coordinate else "$coordinate@jar" + project.dependencies.create(artifactOnly) + }, + ) + } + + val projectDirectory = project.layout.projectDirectory + val referenceDirProperty = + project.providers.gradleProperty("jardiffReferenceDir").filter { it.isNotBlank() } + + val compare = project.tasks.register(COMPARE_TASK_NAME) { + group = "verification" + description = "Compares the built jar against a reference jar (typically the CI `build` " + + "job artifact) using jardiff, failing if they differ. Set the reference with " + + "--reference-jar= or -PjardiffReferenceDir=." + jardiffClasspath.convention(toolClasspath) + mainClass.convention(extension.mainClass) + mode.convention(extension.mode) + additionalOptions.convention(extension.additionalOptions) + reportDir.convention(extension.reportDir) + ignoreHashCheck.convention(extension.ignoreHashCheck) + // Ignore **/*.version by default, except under CI where the build and deploy + // jobs share the same commit. + ignoreVersionFiles.convention( + project.providers.environmentVariable("CI").map { false }.orElse(true), + ) + referenceJar.convention( + // Use the same name as the candidate jar + referenceDirProperty.flatMap { dir -> + candidateJar.map { candidate -> projectDirectory.dir(dir).file(candidate.asFile.name) } + }, + ) + } + + // candidateJar = the module's main publishable archive + project.pluginManager.withPlugin("java") { + compare.configure { + candidateJar.convention( + project.tasks.named("jar").flatMap { it.archiveFile }, + ) + } + } + project.pluginManager.withPlugin("com.gradleup.shadow") { + compare.configure { + candidateJar.set( + project.tasks.named("shadowJar").flatMap { it.archiveFile }, + ) + } + } + } + + companion object { + const val DEFAULT_TOOL_COORDINATE = "io.github.bric3.jardiff:jardiff-cli:0.2.0" + + const val DEFAULT_MAIN_CLASS = "io.github.bric3.jardiff.app.Main" + + const val DEFAULT_MODE = "--stat" + + const val COMPARE_TASK_NAME = "compareToReferenceJar" + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt new file mode 100644 index 00000000000..bf81ba2da93 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -0,0 +1,324 @@ +package datadog.gradle.plugin.jardiff + +import java.io.ByteArrayOutputStream +import java.io.File +import java.security.MessageDigest +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import org.gradle.process.ExecOperations + +/** + * Compares a candidate jar against a reference jar using the + * [jardiff](https://github.com/bric3/jardiff) CLI and fails the build if they differ. + * + * The same task class supports two plugin-configured modes: + * - `compareToReferenceJar` wires [candidateJar] to the project's archive output, so Gradle builds + * that archive before comparing it. + * - `compareJarFiles` leaves [candidateJar] unset and expects `--candidate-jar=`, so it can + * compare an already-produced artifact without depending on `jar` or `shadowJar`. + * + * The reference jar is resolved, in order of precedence, from: + * 1. the `--reference-jar=` command-line option, then + * 2. the [referenceJar] property (wired by the `dd-trace-java.jardiff` plugin from the + * `-PjardiffReferenceDir` project property by matching the built jar's file name in that + * directory). + */ +abstract class JardiffTask @Inject constructor( + private val execOperations: ExecOperations, +) : DefaultTask() { + + /** + * The project archive to validate. Optional so the same task class can also compare explicit + * file paths without depending on the archive-producing task. + */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val candidateJar: RegularFileProperty + + /** + * Command-line override for the candidate jar path; takes precedence over [candidateJar]. + * A relative path is resolved against the current working directory (the Gradle invocation + * directory), matching how CLI users expect paths to behave. + */ + @get:Input + @get:Optional + @get:Option( + option = "candidate-jar", + description = "Path to the candidate jar to compare against the reference jar.", + ) + abstract val candidateJarPath: Property + + /** + * The reference jar to compare against. Optional; usually wired from `-PjardiffReferenceDir`. + * Kept [Internal] (the task always re-runs, see the `upToDateWhen { false }` below) so a missing + * reference produces a clear error message rather than a generic input-validation failure. + */ + @get:Internal + abstract val referenceJar: RegularFileProperty + + /** + * Command-line override for the reference jar path; takes precedence over [referenceJar]. + * A relative path is resolved against the current working directory (the Gradle invocation + * directory), matching how CLI users expect paths to behave. + */ + @get:Input + @get:Optional + @get:Option( + option = "reference-jar", + description = "Path to the reference jar to compare the candidate jar against.", + ) + abstract val referenceJarPath: Property + + /** Runtime classpath hosting the jardiff CLI ([mainClass]). */ + @get:Classpath + abstract val jardiffClasspath: ConfigurableFileCollection + + /** Glob patterns of entries to include in the comparison; empty means compare everything. */ + @get:Input + abstract val includes: ListProperty + + /** Glob patterns of entries to exclude from the comparison. */ + @get:Input + abstract val excludes: ListProperty + + /** + * jardiff output mode flag (e.g. `--stat`, `--status`); blank selects the default full diff. + * Defaulted by the plugin; overridable on the command line. + */ + @get:Input + @get:Option( + option = "mode", + description = "jardiff output mode flag, e.g. --mode=--status (blank selects the default full " + + "diff). Overrides the jardiff extension.", + ) + abstract val mode: Property + + /** + * Extra jardiff options passed verbatim, right before the two jars (e.g. `--ignore-member-order`). + * Defaulted by the plugin; overridable on the command line (repeatable). + */ + @get:Input + @get:Option( + option = "jardiff-option", + description = "Additional jardiff option passed verbatim; repeatable, e.g. " + + "--jardiff-option=--ignore-member-order. Overrides the jardiff extension.", + ) + abstract val additionalOptions: ListProperty + + /** + * When true, tolerate mismatching candidate and reference jar hashes if jardiff reports no + * differences. Matching hashes still skip the more expensive jardiff process. + */ + @get:Input + @get:Option( + option = "ignore-hash-check", + description = "Do not fail when jar hashes differ but jardiff detects no differences.", + ) + abstract val ignoreHashCheck: Property + + /** + * When true, the `.version` files entries are excluded from the comparison. + * This is useful in particular as those files can be part of runtimeClasspath normalization + * which ignores them too. `false` under CI, and true otherwise; overridable on the command line. + */ + @get:Input + @get:Option( + option = "ignore-version-files", + description = "Exclude **/*.version entries (volatile git-hash stamps) from the comparison.", + ) + abstract val ignoreVersionFiles: Property + + /** Fully qualified main class of the jardiff CLI (defaulted by the plugin). Overridable for testing. */ + @get:Input + abstract val mainClass: Property + + /** Directory receiving captured jardiff reports. */ + @get:OutputDirectory + abstract val reportDir: DirectoryProperty + + /** Optional exact destination for the captured jardiff report. Prefer [reportDir]. */ + @get:Internal + abstract val reportFile: RegularFileProperty + + init { + includes.convention(emptyList()) + excludes.convention(emptyList()) + reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff")) + ignoreHashCheck.convention(false) + // These comparisons are explicit verification gates and must never be skipped as up-to-date. + outputs.upToDateWhen { false } + } + + @TaskAction + fun compare() { + val reference = resolveReferenceJar() + val candidate = resolveCandidateJar() + val reportDestination = reportDestination(candidate) + val hashesMatch = sameHash(reference, candidate) + + if (hashesMatch) { + val message = "SHA-256 hashes match for ${candidate.name} and ${reference.name}." + writeReport(reportDestination, "$message\n") + logger.info("Skipping jardiff for ${candidate.name} because SHA-256 hashes match.") + return + } + + val effectiveExcludes = buildList { + addAll(excludes.get()) + if (ignoreVersionFiles.get()) { + add("**/*.version") + } + } + val arguments = JardiffComparison.buildArguments( + reference = reference, + candidate = candidate, + mode = mode.get(), + includes = includes.get(), + excludes = effectiveExcludes, + additionalOptions = additionalOptions.get(), + ) + + val toolClasspath = jardiffClasspath + val mainClassName = mainClass.get() + logger.info( + "jardiff command: {}", + JardiffComparison.shellCommandLine(toolClasspath.files, mainClassName, arguments), + ) + val captured = ByteArrayOutputStream() + val execResult = execOperations.javaexec { + classpath = toolClasspath + mainClass.set(mainClassName) + args(arguments) + standardOutput = captured + errorOutput = captured + isIgnoreExitValue = true + } + + val report = captured.toString("UTF-8") + writeReport(reportDestination, report) + + when (JardiffComparison.outcomeOf(execResult.exitValue)) { + JardiffComparison.Outcome.IDENTICAL -> { + if (!hashesMatch) { + val message = + "SHA-256 hashes differ for ${candidate.name} (candidate) and ${reference.name} (reference), " + + "but jardiff detected no differences." + logger.warn(message) + if (!ignoreHashCheck.get()) { + throw GradleException( + buildString { + appendLine(message) + appendLine() + appendLine(" candidate : ${candidate.absolutePath}") + appendLine(" reference : ${reference.absolutePath}") + appendLine(" report : ${reportDestination.absolutePath}") + }, + ) + } + } + logger.lifecycle( + "Jardiff comparison passed for ${candidate.name} (candidate) against ${reference.name} (reference).", + ) + } + + JardiffComparison.Outcome.DIFFERENT -> + throw GradleException( + buildString { + appendLine("Candidate jar differs from the reference jar.") + appendLine("TODO: inspect gradle build scripts") + appendLine() + appendLine(" candidate : ${candidate.absolutePath}") + appendLine(" reference : ${reference.absolutePath}") + appendLine(" report : ${reportDestination.absolutePath}") + appendLine() + appendLine("jardiff report:") + append(report.ifBlank { "(no output captured)" }) + }, + ) + + JardiffComparison.Outcome.ERROR -> + throw GradleException( + "jardiff failed with exit code ${execResult.exitValue} while comparing " + + "${candidate.name} (candidate) against ${reference.name} (reference)." + + "Output:\n" + + report.ifBlank { "(no output captured)" }, + ) + } + } + + private fun resolveReferenceJar(): File = + referenceJarPath.orNull?.takeIf { it.isNotBlank() }?.let(::File)?.let { + requireExistingJar(it, "Reference jar") + } ?: when { + referenceJar.isPresent -> requireExistingJar(referenceJar.get().asFile, "Reference jar") + else -> throw GradleException( + "No reference jar configured to compare the candidate jar against.\n" + + "Provide one via --reference-jar= or -PjardiffReferenceDir= (the directory " + + "holding the `build` job artifacts), or disable this task for modules with no reference.", + ) + } + + private fun resolveCandidateJar(): File = + candidateJarPath.orNull?.takeIf { it.isNotBlank() }?.let(::File)?.let { + requireExistingJar(it, "Candidate jar") + } ?: when { + candidateJar.isPresent -> requireExistingJar(candidateJar.get().asFile, "Candidate jar") + else -> throw GradleException( + "No candidate jar configured to compare against the reference jar.\n" + + "Pass an existing jar via --candidate-jar= or configure the task's candidateJar.", + ) + } + + private fun requireExistingJar(jar: File, role: String): File { + if (!jar.isFile) { + throw GradleException("$role does not exist: ${jar.absolutePath}") + } + return jar + } + + private fun reportDestination(candidate: File): File = + when { + reportFile.isPresent -> reportFile.get().asFile + else -> reportDir.file("${candidate.name}.txt").get().asFile + } + + private fun sameHash(left: File, right: File): Boolean = + left.length() == right.length() && sha256(left).contentEquals(sha256(right)) + + private fun sha256(file: File): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().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() + } + + private fun writeReport(reportDestination: File, report: String) { + reportDestination.parentFile?.mkdirs() + reportDestination.writeText(report) + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt new file mode 100644 index 00000000000..fded087aefd --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt @@ -0,0 +1,95 @@ +package datadog.gradle.plugin.jardiff + +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class JardiffComparisonTest { + + private val reference = File("/tmp/reference/dd-java-agent-1.0.0.jar") + private val candidate = File("/tmp/candidate/dd-java-agent-1.0.0.jar") + + @Test + fun `builds stat comparison arguments with the reference on the left`() { + val arguments = JardiffComparison.buildArguments(reference, candidate, mode = "--stat") + + assertThat(arguments).containsExactly( + "--stat", + "--exit-code", + "--color=never", + reference.absolutePath, + candidate.absolutePath, + ) + } + + @Test + fun `joins include patterns with commas before the positional arguments`() { + val arguments = JardiffComparison.buildArguments( + reference, + candidate, + mode = "--stat", + includes = listOf("**/*.class", "META-INF/**"), + ) + + assertThat(arguments).containsExactly( + "--stat", + "--exit-code", + "--color=never", + "--include=**/*.class,META-INF/**", + reference.absolutePath, + candidate.absolutePath, + ) + } + + @Test + fun `joins exclude patterns with commas`() { + val arguments = JardiffComparison.buildArguments( + reference, + candidate, + mode = "--stat", + excludes = listOf("**/*.txt"), + ) + + assertThat(arguments).contains("--exclude=**/*.txt") + // Both filter flags precede the two jars. + assertThat(arguments.indexOf("--exclude=**/*.txt")) + .isLessThan(arguments.indexOf(reference.absolutePath)) + } + + @Test + fun `omits filter flags when no patterns are given`() { + val arguments = JardiffComparison.buildArguments(reference, candidate, mode = "--stat") + + assertThat(arguments).noneMatch { it.startsWith("--include") || it.startsWith("--exclude") } + } + + @Test + fun `uses the given mode, and omits it when blank`() { + assertThat(JardiffComparison.buildArguments(reference, candidate, mode = "--status")) + .startsWith("--status") + assertThat(JardiffComparison.buildArguments(reference, candidate, mode = "")) + .startsWith("--exit-code") + } + + @Test + fun `appends additional options before the positional arguments`() { + val arguments = JardiffComparison.buildArguments( + reference, + candidate, + mode = "--stat", + additionalOptions = listOf("--ignore-member-order", "--class-text-producer=javap"), + ) + + assertThat(arguments).contains("--ignore-member-order", "--class-text-producer=javap") + assertThat(arguments.indexOf("--ignore-member-order")) + .isLessThan(arguments.indexOf(reference.absolutePath)) + } + + @Test + fun `maps exit values to outcomes like diff`() { + assertThat(JardiffComparison.outcomeOf(0)).isEqualTo(JardiffComparison.Outcome.IDENTICAL) + assertThat(JardiffComparison.outcomeOf(1)).isEqualTo(JardiffComparison.Outcome.DIFFERENT) + assertThat(JardiffComparison.outcomeOf(2)).isEqualTo(JardiffComparison.Outcome.ERROR) + assertThat(JardiffComparison.outcomeOf(137)).isEqualTo(JardiffComparison.Outcome.ERROR) + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt new file mode 100644 index 00000000000..d2fe632d7a4 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -0,0 +1,282 @@ +package datadog.gradle.plugin.jardiff + +import datadog.gradle.plugin.GradleFixture +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Test + +/** + * Exercises the `dd-trace-java.jardiff` plugin and its `compareToReferenceJar` task end-to-end. + */ +class JardiffTaskIntegrationTest : GradleFixture() { + + @Test + fun `reference-jar option takes precedence over the referenceJar property`() { + writeProject() + writeJar("candidate.jar", "shared-bytes") + // The wired reference would match (identical bytes) and pass... + writeJar("reference.jar", "shared-bytes") + // ...but the command-line override points at a diverging jar, so the build must fail. + writeJar("override.jar", "diverging-bytes") + + val result = run( + "compareToReferenceJar", + "--reference-jar=${file("override.jar").absolutePath}", + expectFailure = true, + ) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output).contains("Candidate jar differs from the reference jar") + } + + @Test + fun `resolves the reference from jardiffReferenceDir by matching the candidate file name`() { + writeProject(referenceJarLine = "") + writeJar("candidate.jar", "dir-bytes") + // A directory holding a jar with the SAME file name as the candidate (as across CI jobs). + dir("refs") + writeJar("refs/candidate.jar", "dir-bytes") + + val result = run("compareToReferenceJar", "-PjardiffReferenceDir=${file("refs").absolutePath}") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).exists().content() + .contains("SHA-256 hashes match for candidate.jar and candidate.jar") + } + + @Test + fun `skips comparison when the task is disabled`() { + writeProject( + referenceJarLine = "", + taskBody = "enabled = false", + ) + writeJar("candidate.jar", "candidate-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SKIPPED) + assertThat(result.output).doesNotContain("No reference jar configured") + assertThat(jardiffReport()).doesNotExist() + } + + @Test + fun `passes include and exclude patterns to jardiff`() { + writeProject( + taskBody = """ + includes.set(listOf("**/*.class", "META-INF/**")) + excludes.set(listOf("**/*.txt")) + ignoreHashCheck.set(true) + """, + compareBytes = false, + ) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).content() + .contains("--include=**/*.class,META-INF/**") + .contains("--exclude=**/*.txt") + } + + @Test + fun `applies main class, mode and additional options from the jardiff extension`() { + writeSettings("""rootProject.name = "jardiff-ext-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff", compareBytes = false)) + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + jardiff { + mainClass.set("fake.jardiff.Main") + mode.set("--status") + additionalOptions.set(listOf("--ignore-member-order")) + ignoreHashCheck.set(true) + } + + tasks.named("compareToReferenceJar") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + referenceJar.set(layout.projectDirectory.file("reference.jar")) + } + """, + ) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("compareToReferenceJar") + + // SUCCESS proves the extension's mainClass reached the task (the stub Main is 'fake.jardiff.Main', + // not the default), and the report echoes the mode + additional option from the extension. + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).content() + .contains("--status") + .contains("--ignore-member-order") + } + + @Test + fun `applies mode additional options and ignoreHashCheck passed as command-line options`() { + writeProject(compareBytes = false) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run( + "compareToReferenceJar", + "--mode=--status", + "--jardiff-option=--ignore-member-order", + "--jardiff-option=--class-text-producer=javap", + "--ignore-hash-check", + ) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).content() + .contains("--status") + .contains("--ignore-member-order") + .contains("--class-text-producer=javap") + } + + @Test + fun `ignoreVersionFiles excludes version stamps from the comparison`() { + writeProject( + taskBody = """ + ignoreVersionFiles.set(true) + ignoreHashCheck.set(true) + """, + compareBytes = false, + ) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).content() + .contains("--exclude=**/*.version") + } + + @Test + fun `compares version stamps under CI (ignoreVersionFiles defaults to false)`() { + // No explicit ignoreVersionFiles: the plugin's CI-aware convention drives it. With CI set, the + // build and deploy jobs share a commit, so the stamps are compared (not excluded). + writeProject(taskBody = "ignoreHashCheck.set(true)", compareBytes = false) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("compareToReferenceJar", env = mapOf("CI" to "true")) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).content() + .doesNotContain("**/*.version") + } + + @Test + fun `writes reports under the configured reportDir`() { + writeSettings("""rootProject.name = "jardiff-report-dir-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff")) + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + jardiff { + reportDir.set(layout.buildDirectory.dir("custom-jardiff-reports")) + } + + tasks.named("compareToReferenceJar") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + mainClass.set("fake.jardiff.Main") + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + referenceJar.set(layout.projectDirectory.file("reference.jar")) + } + """, + ) + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("custom-jardiff-reports/candidate.jar.txt")).exists().content() + .contains("SHA-256 hashes match for candidate.jar and reference.jar") + } + + private fun writeJar(name: String, content: String) { + file(name).also { it.parentFile?.mkdirs() }.writeBytes(content.toByteArray()) + } + + private fun jardiffReport(candidateName: String = "candidate.jar") = + buildFile("reports/jardiff/$candidateName.txt") + + private fun writeProject( + taskBody: String = "", + referenceJarLine: String = + """referenceJar.set(layout.projectDirectory.file("reference.jar"))""", + compareBytes: Boolean = true, + ) { + writeSettings("""rootProject.name = "jardiff-stub-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff", compareBytes)) + // The task's classpath and main class point at the compiled stub instead of the real jardiff CLI. + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + tasks.named("compareToReferenceJar") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + mainClass.set("fake.jardiff.Main") + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + $referenceJarLine + $taskBody + } + """, + ) + } + + companion object { + /** + * Minimal stand-in for the jardiff CLI in the given package: prints the received arguments, + * byte-compares the last two positional arguments (left/right jars) and mirrors jardiff's + * `--exit-code` semantics. + */ + private fun stubMainSource(packageName: String, compareBytes: Boolean = true): String = """ + package $packageName; + + import java.nio.file.Files; + import java.nio.file.Paths; + import java.util.Arrays; + + public class Main { + public static void main(String[] args) throws Exception { + System.out.println("jardiff-stub args: " + Arrays.toString(args)); + String left = args[args.length - 2]; + String right = args[args.length - 1]; + byte[] leftBytes = Files.readAllBytes(Paths.get(left)); + byte[] rightBytes = Files.readAllBytes(Paths.get(right)); + if (${if (compareBytes) "Arrays.equals(leftBytes, rightBytes)" else "true"}) { + System.out.println("no differences"); + System.exit(0); + } + System.out.println(" 1 file changed, 1 insertion(+)"); + System.exit(Arrays.asList(args).contains("--exit-code") ? 1 : 0); + } + } + """.trimIndent() + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt new file mode 100644 index 00000000000..ea06e336526 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt @@ -0,0 +1,151 @@ +package datadog.gradle.plugin.jardiff + +import datadog.gradle.plugin.GradleFixture +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Test + +/** Exercises [JardiffTask] behavior without the plugin's archive/reference-dir wiring. */ +class JardiffTaskTest : GradleFixture() { + + @Test + fun `passes without running jardiff when the candidate hash matches the reference hash`() { + writeProject() + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("jardiffTask") + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(jardiffReport()).exists().content() + .contains("SHA-256 hashes match for candidate.jar and reference.jar") + } + + @Test + fun `fails and reports the diff when jardiff finds differences`() { + writeProject() + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("jardiffTask", expectFailure = true) + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output) + .contains("Candidate jar differs from the reference jar") + .contains("1 file changed") + .doesNotContain("SHA-256 hashes differ") + assertThat(jardiffReport()).exists().content() + .contains("1 file changed") + } + + @Test + fun `fails when no reference is configured`() { + writeProject(referenceJarLine = "") + writeJar("candidate.jar", "candidate-bytes") + + val result = run("jardiffTask", expectFailure = true) + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output).contains("No reference jar configured") + assertThat(jardiffReport()).doesNotExist() + } + + @Test + fun `fails when hashes differ but jardiff reports no differences and ignoreHashCheck is false`() { + writeProject(compareBytes = false) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("jardiffTask", expectFailure = true) + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output) + .contains("SHA-256 hashes differ for candidate.jar (candidate) and reference.jar (reference)") + .contains("but jardiff detected no differences") + assertThat(jardiffReport()).exists().content() + .contains("no differences") + } + + @Test + fun `warns when hashes differ but jardiff reports no differences and ignoreHashCheck is true`() { + writeProject( + taskBody = "ignoreHashCheck.set(true)", + compareBytes = false, + ) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("jardiffTask") + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(result.output) + .contains("SHA-256 hashes differ for candidate.jar (candidate) and reference.jar (reference)") + .contains("but jardiff detected no differences") + assertThat(jardiffReport()).exists().content() + .contains("no differences") + } + + private fun writeJar(name: String, content: String) { + file(name).also { it.parentFile?.mkdirs() }.writeBytes(content.toByteArray()) + } + + private fun jardiffReport(candidateName: String = "candidate.jar") = + buildFile("reports/jardiff/$candidateName.txt") + + private fun writeProject( + taskBody: String = "", + referenceJarLine: String = + """referenceJar.set(layout.projectDirectory.file("reference.jar"))""", + compareBytes: Boolean = true, + ) { + writeSettings("""rootProject.name = "jardiff-task-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff", compareBytes)) + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + tasks.register("jardiffTask") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + mainClass.set("fake.jardiff.Main") + mode.set("--stat") + ignoreVersionFiles.set(true) + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + $referenceJarLine + $taskBody + } + """, + ) + } + + companion object { + private fun stubMainSource(packageName: String, compareBytes: Boolean = true): String = """ + package $packageName; + + import java.nio.file.Files; + import java.nio.file.Paths; + import java.util.Arrays; + + public class Main { + public static void main(String[] args) throws Exception { + System.out.println("jardiff-stub args: " + Arrays.toString(args)); + String left = args[args.length - 2]; + String right = args[args.length - 1]; + byte[] leftBytes = Files.readAllBytes(Paths.get(left)); + byte[] rightBytes = Files.readAllBytes(Paths.get(right)); + if (${if (compareBytes) "Arrays.equals(leftBytes, rightBytes)" else "true"}) { + System.out.println("no differences"); + System.exit(0); + } + System.out.println(" 1 file changed, 1 insertion(+)"); + System.exit(Arrays.asList(args).contains("--exit-code") ? 1 : 0); + } + } + """.trimIndent() + } +} diff --git a/dd-trace-api/build.gradle.kts b/dd-trace-api/build.gradle.kts index 46e975af41f..70a003348c9 100644 --- a/dd-trace-api/build.gradle.kts +++ b/dd-trace-api/build.gradle.kts @@ -5,6 +5,11 @@ plugins { apply(from = "$rootDir/gradle/java.gradle") apply(from = "$rootDir/gradle/publish.gradle") +configure { + // jar is not cacheable, and may differ + ignoreHashCheck = true +} + extra["minimumBranchCoverage"] = 0.8 // These are tested outside of this module since this module mainly just defines 'API' diff --git a/gradle/publish.gradle b/gradle/publish.gradle index 86d06ae9a85..b95913eaf07 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -1,6 +1,7 @@ apply plugin: 'maven-publish' apply plugin: 'signing' apply plugin: 'dd-trace-java.version-file' +apply plugin: 'dd-trace-java.jardiff' /** * Proper publishing requires the following environment variables: diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index ba8b43ca32c..88531ceaeed 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -77,3 +77,9 @@ tasks.withType().configureEach { tasks.withType().configureEach { javadocTool = javaToolchains.javadocToolFor(java.toolchain) } + +// The dd-openfeature provider jar is not produced by the CI `build` job, so there is no reference +// artifact to compare against. Disable the release jar comparison gate registered by publish.gradle. +tasks.named("compareToReferenceJar") { + enabled = false +}