diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt index 42379795a86..dea0c63e7cf 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt @@ -9,6 +9,7 @@ import org.eclipse.aether.artifact.Artifact import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.artifacts.Configuration import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.create @@ -26,8 +27,7 @@ class MuzzlePlugin : Plugin { * * This function sets up the necessary configurations, dependencies, and tasks for muzzle validation. * It registers the muzzle extension, configures bootstrap/tooling dependencies, and sets up tasks for - * compiling, running, printing, and reporting muzzle checks. It also optimizes configuration overhead - * by only proceeding if relevant muzzle tasks are requested. + * compiling, running, printing, and reporting muzzle checks. Validation tasks are registered lazily. * * @param project The Gradle project to apply the plugin to. */ @@ -101,38 +101,22 @@ class MuzzlePlugin : Plugin { } project.publishMuzzleReport(report.flatMap { it.versionsFile }) - val hasRelevantTask = project.gradle.startParameter.taskNames.any { taskName -> - val taskProjectPath = taskName.substringBeforeLast(":", "") - val taskNameOnly = taskName.substringAfterLast(":") - val isRelevantForProject = taskProjectPath.isEmpty() || taskProjectPath == project.path - - isRelevantForProject && taskNameOnly.endsWith("muzzle", ignoreCase = true) - } - if (!hasRelevantTask) { - // Adding muzzle dependencies has a large config overhead. Stop unless muzzle is explicitly run. - project.logger.info("No muzzle tasks invoked for ${project.path}, skipping muzzle task planification") - return - } - - // We only get here if we are running muzzle, so let's start timing things - val startTime = System.currentTimeMillis() - - val system = MuzzleMavenRepoUtils.newRepositorySystem() - val session = MuzzleMavenRepoUtils.newRepositorySystemSession(system) - val taskPlanner = MuzzleTaskPlanner.from(system, session) - project.afterEvaluate { - // use runAfter to set up task finalizers in version order - var runAfter: TaskProvider = muzzleTask - val muzzleReportTasks = mutableListOf>() + val validationTasks = MuzzleValidationTasks(project.providers) { + val startTime = System.currentTimeMillis() + val system = MuzzleMavenRepoUtils.newRepositorySystem() + val session = MuzzleMavenRepoUtils.newRepositorySystemSession(system) + val taskPlanner = MuzzleTaskPlanner.from(system, session) val directives = project.extensions.getByType().directives - taskPlanner.plan(directives).forEach { plan -> - runAfter = registerMuzzleTask(plan.directive, plan.artifact, project, runAfter, muzzleBootstrap, muzzleTooling) - muzzleReportTasks.add(runAfter) + val checks = taskPlanner.plan(directives).map { plan -> + val check = registerMuzzleTask(plan.directive, plan.artifact, project, muzzleBootstrap, muzzleTooling) project.logger.info("configured ${plan.directive}") + check } - if (muzzleReportTasks.isEmpty() && !directives.any { it.assertPass }) { - muzzleReportTasks.add(muzzleTask) + val muzzleReportTasks = if (checks.isEmpty() && !directives.any { it.assertPass }) { + listOf(muzzleTask) + } else { + checks } val timingTask = project.tasks.register("muzzle-end") { @@ -144,10 +128,17 @@ class MuzzlePlugin : Plugin { ) muzzleResultFiles.from(muzzleReportTasks.map { it.flatMap { task -> task.result } }) } - // last muzzle task to run - runAfter.configure { - finalizedBy(timingTask) + + val executionChain: List> = checks + timingTask + executionChain.zipWithNext { current, next -> + current.configure { finalizedBy(next) } } + + // Note this is a set, but only the first task of the chain is passed. + setOf(executionChain.first().get()) + } + muzzleTask.configure { + finalizedBy(validationTasks.tasks) } } @@ -161,7 +152,6 @@ class MuzzlePlugin : Plugin { * @param muzzleDirective The directive describing the dependency and test parameters. * @param versionArtifact The artifact representing the dependency version to test (may be null when `muzzleDirective.coreJdk` is true). * @param instrumentationProject The Gradle project to register the task in. - * @param runAfterTask The task provider for the task that this muzzle task should run after. * @param muzzleBootstrap The configuration provider for agent bootstrap dependencies. * @param muzzleTooling The configuration provider for agent tooling dependencies. * @return The muzzle task provider. @@ -170,7 +160,6 @@ class MuzzlePlugin : Plugin { muzzleDirective: MuzzleDirective, versionArtifact: Artifact?, instrumentationProject: Project, - runAfterTask: TaskProvider, muzzleBootstrap: NamedDomainObjectProvider, muzzleTooling: NamedDomainObjectProvider ): TaskProvider { @@ -238,9 +227,6 @@ class MuzzlePlugin : Plugin { this.muzzleTooling.set(muzzleTooling) } - runAfterTask.configure { - finalizedBy(muzzleTask) - } return muzzleTask } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasks.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasks.kt new file mode 100644 index 00000000000..5f44da43158 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasks.kt @@ -0,0 +1,41 @@ +package datadog.gradle.plugin.muzzle + +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.TaskProvider + +/** + * Registers validation tasks when Gradle resolves a module's muzzle finalizers. + * + * [tasks] is a plain [Provider] used as dependency notation by [Task.finalizedBy]. During normal + * task-graph construction, Gradle resolves its task-set value after project evaluation and before + * execution. Registration therefore runs outside task configuration actions and can use completed + * directives to discover checks whose names were unknown when the plugin was applied. + * + * A [TaskProvider] references an already registered task. A task-backed [Provider.map] can supply + * producer dependencies without evaluating its mapping, so registration must not be a side effect + * of that mapping. [ProviderFactory.provider] supplies the task-set value directly instead. + * + * Providers can be queried repeatedly. Cache both success and failure to avoid duplicate task + * registration, including retries after partial registration. + * + * Note that [org.gradle.api.tasks.TaskDependency], a mechanism to supplying directly the **concrete** + * tasks when assembling the execution graph, exists today but is on the side-line possibly + * getting deprecated for removal. + * + * shortcut: generated-check CLI selectors remain unsupported; revisit if individual checks + * need to be selected or excluded. + * + * @see ProviderFactory.provider API + * @see TaskProvider API + * @see Discussion on removing older Gradle interface API + */ +internal class MuzzleValidationTasks( + providerFactory: ProviderFactory, + registerValidationTasks: () -> Set +) { + private val registration by lazy { runCatching(registerValidationTasks) } + + val tasks: Provider> = providerFactory.provider { registration.getOrThrow() } +} diff --git a/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts b/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts index 4868f4f769b..39a86ce9cad 100644 --- a/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts +++ b/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts @@ -124,9 +124,9 @@ if (gitBaseRefProvider.isPresent) { tasks.register("runMuzzle") { val muzzleSubprojects = subprojects.filter { p -> - p.isInSelectedSlot.get() - && p.plugins.hasPlugin("java") - && p.plugins.hasPlugin("dd-trace-java.muzzle") + p.plugins.hasPlugin("java") && + p.plugins.hasPlugin("dd-trace-java.muzzle") && + p.isInSelectedSlot.get() } dependsOn(muzzleSubprojects.map { p -> "${p.path}:muzzle" }) } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt index 2e0e5ebe213..75107a2f41a 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt @@ -9,8 +9,31 @@ import kotlin.io.path.readText class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { @ParameterizedTest - @ValueSource(strings = ["muzzle", ":dd-java-agent:instrumentation:demo:muzzle", "runMuzzle"]) - fun `detects muzzle invocation with various task names`(taskName: String) { + @ValueSource(strings = [":dd-java-agent:runMuzzle", "dd-java-agent:runMuzzle"]) + fun `nested aggregate plans descendant instrumentation checks`(taskName: String) { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeNoopScanPlugin() + writeFile("dd-java-agent/build.gradle.kts", + """ + tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } + """ + ) + val result = run(taskName) + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome).isEqualTo(SUCCESS) + } + + @ParameterizedTest + @ValueSource(strings = ["muzzle", ":dd-java-agent:instrumentation:demo:muzzle", "runMuzzle", "verifyCompatibility"]) + fun `runs validation through different task entry points`(taskName: String) { writeProject( """ plugins { @@ -32,6 +55,9 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } + tasks.register("verifyCompatibility") { + dependsOn(":dd-java-agent:instrumentation:demo:muzzle") + } """ ) @@ -39,14 +65,13 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { val result = run(taskName, "--stacktrace") - assertThat(result.tasks) - .withFailMessage("Should create muzzle tasks when '$taskName' is requested") - .anyMatch { it.path.contains("muzzle") } - assertThat(result.output) - .withFailMessage("Should not skip muzzle task planification when '$taskName' is requested") - .doesNotContain("No muzzle tasks invoked, skipping muzzle task planification") - assertThat(result.tasks).withFailMessage("Should execute muzzle tasks when '$taskName' is requested") - .anyMatch { it.path == ":dd-java-agent:instrumentation:demo:muzzle" || it.path.contains("muzzle-Assert") } + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("Should execute validation through '$taskName'") + .isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome) + .withFailMessage("Should execute reporting through '$taskName'") + .isEqualTo(SUCCESS) } @Test diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt index 2e43b0b3d38..b89c90f74c5 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt @@ -3,9 +3,113 @@ package datadog.gradle.plugin.muzzle import org.gradle.testkit.runner.TaskOutcome.SUCCESS import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.assertj.core.api.Assertions.assertThat +import kotlin.math.abs class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { + private fun writeCoreProject() { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeNoopScanPlugin() + } + + @Test + fun `compileMuzzle does not resolve version directives or schedule checks`() { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } + } + """ + ) + val result = run(":dd-java-agent:instrumentation:demo:compileMuzzle", "--offline", "--info") + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.tasks).noneMatch { it.path.contains("muzzle-Assert") || it.path.endsWith("muzzle-end") } + assertThat(result.task(":dd-java-agent:instrumentation:demo:compileMuzzle")?.outcome).isEqualTo(UP_TO_DATE) + } + + @Test + fun `aggregate does not plan an excluded module`() { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } + } + """ + ) + writeRootProject( + """ + tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } + """ + ) + + val result = run(":runMuzzle", "-x", ":dd-java-agent:instrumentation:demo:muzzle", "--offline") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.tasks).noneMatch { it.path.startsWith(":dd-java-agent:instrumentation:demo:") } + assertThat(result.output).doesNotContain("muzzle-Assert", "muzzle-end") + } + + @Test + fun `aggregate skips excluded plans but explicit muzzle still checks them`() { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeNoopScanPlugin() + addSubproject("dd-java-agent:instrumentation:other", + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeRootProject( + """ + tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } + """ + ) + val aggregate = run("runMuzzle", "--info") + assertThat(aggregate.output).contains("BUILD SUCCESSFUL") + assertThat(aggregate.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome).isEqualTo(SUCCESS) + assertThat(aggregate.tasks).noneMatch { it.path.contains(":other:") } + val explicit = run(":dd-java-agent:instrumentation:other:muzzle") + assertThat(explicit.output).contains("BUILD SUCCESSFUL") + assertThat(explicit.task(":dd-java-agent:instrumentation:other:muzzle-AssertPass-core-jdk")?.outcome).isEqualTo(SUCCESS) + } + @Test fun `task graph does not include muzzle tasks when not requested`() { @@ -18,8 +122,8 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { muzzle { pass { - group = "com.example.test" - module = "some-lib" + group = "does.not.exist" + module = "must-not-resolve" versions = "[1.0.0,2.0.0)" } } @@ -30,21 +134,21 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { val result = run( ":dd-java-agent:instrumentation:demo:tasks", "--all", + "--offline", "--info" ) assertThat(result.task(":dd-java-agent:instrumentation:demo:tasks")?.outcome).isEqualTo(SUCCESS) + assertThat(result.output).contains("BUILD SUCCESSFUL") assertThat(result.tasks) .withFailMessage("Should not create or execute any muzzle tasks when not requested") .noneMatch { it.path.contains("muzzle") } - assertThat(result.output) - .withFailMessage("Should log early return when muzzle not requested") - .contains("No muzzle tasks invoked for :dd-java-agent:instrumentation:demo, skipping muzzle task planification") + assertThat(result.output).doesNotContain("muzzle-Assert", "muzzle-end") } @Test - fun `does not configure muzzle when other project muzzle task is requested`() { + fun `does not plan checks when another project's muzzle task is requested`() { writeProject( """ plugins { @@ -64,26 +168,105 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { id("dd-trace-java.muzzle") } muzzle { - pass { coreJdk() } + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } } """ ) val result = run( ":dd-java-agent:instrumentation:demo:muzzle", + "--offline", "--stacktrace", "--info" ) + assertThat(result.output).contains("BUILD SUCCESSFUL") assertThat(result.tasks) .withFailMessage("Should execute muzzle tasks for demo project") .anyMatch { it.path.contains("demo") && it.path.contains("muzzle") } assertThat(result.tasks) .withFailMessage("Should NOT create or register execute muzzle tasks for other project") .noneMatch { it.path.contains("other") && it.path.contains("muzzle") } - assertThat(result.output.lines()) - .withFailMessage("Other project should skip muzzle configuration when demo project's muzzle is requested") - .anyMatch { it.contains("No muzzle tasks invoked for :dd-java-agent:instrumentation:other, skipping muzzle task planification") } + } + + @ParameterizedTest + @ValueSource(ints = [1, 2]) + fun `CI aggregate runs only the module assigned to its slot`(slot: Int) { + writeCoreProject() + addSubproject("dd-java-agent:instrumentation:other", + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeRootProject("""plugins { id("dd-trace-java.ci-jobs") }""") + val demoSlot = abs(":dd-java-agent:instrumentation:demo".hashCode() % 2) + 1 + val otherSlot = abs(":dd-java-agent:instrumentation:other".hashCode() % 2) + 1 + assertThat(demoSlot).isNotEqualTo(otherSlot) + + val result = run(":runMuzzle", "-Pslot=$slot/2") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + val selectedModule = if (slot == demoSlot) "demo" else "other" + val unselectedModule = if (slot == demoSlot) "other" else "demo" + assertThat(result.task(":dd-java-agent:instrumentation:$selectedModule:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.tasks).noneMatch { it.path.contains(":$unselectedModule:") } + } + + @Test + fun `CI aggregate does not resolve off-slot directives`() { + writeCoreProject() + addSubproject("dd-java-agent:instrumentation:other", + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } + } + """ + ) + writeRootProject("""plugins { id("dd-trace-java.ci-jobs") }""") + val demoSlot = abs(":dd-java-agent:instrumentation:demo".hashCode() % 2) + 1 + + val result = run(":runMuzzle", "-Pslot=$demoSlot/2", "--offline") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.tasks).noneMatch { it.path.contains(":other:") } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `explicit module check bypasses CI slot selection`(includeAggregate: Boolean) { + writeCoreProject() + writeRootProject("""plugins { id("dd-trace-java.ci-jobs") }""") + val demoSlot = abs(":dd-java-agent:instrumentation:demo".hashCode() % 2) + 1 + val taskNames = if (includeAggregate) { + arrayOf(":runMuzzle", ":dd-java-agent:instrumentation:demo:muzzle") + } else { + arrayOf(":dd-java-agent:instrumentation:demo:muzzle") + } + + val result = run(*taskNames, "-Pslot=${3 - demoSlot}/2") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) } @Test diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasksTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasksTest.kt new file mode 100644 index 00000000000..8cd396cd975 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasksTest.kt @@ -0,0 +1,47 @@ +package datadog.gradle.plugin.muzzle + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test + +class MuzzleValidationTasksTest { + @Test + fun `registration is deferred and repeated provider queries reuse the tasks`() { + val project = ProjectBuilder.builder().build() + var attempts = 0 + val validationTasks = MuzzleValidationTasks(project.providers) { + attempts++ + setOf(project.tasks.register("validation").get()) + } + + assertThat(attempts).isZero() + assertThat(project.tasks.names).doesNotContain("validation") + + val first = validationTasks.tasks.get() + val second = validationTasks.tasks.get() + + assertThat(attempts).isEqualTo(1) + assertThat(first).containsExactly(project.tasks.named("validation").get()) + assertThat(second).isSameAs(first) + } + + @Test + fun `failed registration is not retried after registering a task`() { + val project = ProjectBuilder.builder().build() + val failure = IllegalStateException("Registration failed") + var attempts = 0 + val validationTasks = MuzzleValidationTasks(project.providers) { + attempts++ + project.tasks.register("partiallyRegistered") + throw failure + } + + repeat(2) { + assertThatThrownBy { validationTasks.tasks.get() }.isSameAs(failure) + } + + assertThat(attempts).isEqualTo(1) + assertThat(project.tasks.names).contains("partiallyRegistered") + } +}