elastic/elasticsearch · error · IllegalStateException

{} can only be applied to the root project.

Error message

{} can only be applied to the root project.

What it means

TestClustersHookPlugin.apply() refuses to be applied to any project other than the root. This plugin registers build-wide services (TestClustersRegistry provider, TaskEventsService) via `getEventsListenerRegistry()` and the gradle shared-services API, which only make sense once at the build root. Applying it to a subproject would create duplicate/conflicting build services.

Source

Thrown at build-tools/src/main/java/org/elasticsearch/gradle/testclusters/TestClustersPlugin.java:220

            task.setGroup("ES cluster formation");
            task.setDescription("Lists all ES clusters configured for this project");
            task.doLast(
                (Task t) -> container.forEach(cluster -> logger.lifecycle("   * {}: {}", cluster.getName(), cluster.getNumberOfNodes()))
            );
        });
    }

    static abstract class TestClustersHookPlugin implements Plugin<Project> {
        @Inject
        public abstract BuildEventsListenerRegistry getEventsListenerRegistry();

        @SuppressWarnings("checkstyle:RedundantModifier")
        @Inject
        public TestClustersHookPlugin() {}

        public void apply(Project project) {
            if (project != project.getRootProject()) {
                throw new IllegalStateException(this.getClass().getName() + " can only be applied to the root project.");
            }
            Provider<TestClustersRegistry> registryProvider = GradleUtils.getBuildService(
                project.getGradle().getSharedServices(),
                REGISTRY_SERVICE_NAME
            );

            Provider<TaskEventsService> testClusterTasksService = project.getGradle()
                .getSharedServices()
                .registerIfAbsent(TEST_CLUSTER_TASKS_SERVICE, TaskEventsService.class, spec -> {
                    spec.getParameters().getRegistry().set(registryProvider);
                });

            TestClustersRegistry registry = registryProvider.get();
            // When we know what tasks will run, we claim the clusters of those task to differentiate between clusters
            // that are defined in the build script and the ones that will actually be used in this invocation of gradle
            // we use this information to determine when the last task that required the cluster executed so that we can
            // terminate the cluster right away and free up resources.
            configureClaimClustersHook(project.getGradle(), registry);

View on GitHub (pinned to db6a809a66)

Solutions

  1. Apply the hook plugin ONLY in the root project's build.gradle (or via a plugin that itself checks `project == rootProject`).
  2. If writing a convention, guard with `if (project == project.rootProject) { apply(plugin) }`.
  3. Confirm the plugin isn't transitively pulled by another plugin applied to subprojects — check `./gradlew <sub>:dependencies` or plugin application logging.

Example fix

// before (subproject build.gradle)
apply plugin: 'elasticsearch.testclusters'
// after: move to root build.gradle, or guard
if (project == rootProject) {
  apply plugin: 'elasticsearch.testclusters'
}
Defensive patterns

Strategy: validation

Validate before calling

if (project != project.getRootProject()) {
  throw new IllegalStateException(
    "Apply " + pluginId + " only in the root project, not " + project.getPath());
}

Prevention

When it happens

Trigger: Explicitly `apply plugin: 'elasticsearch.testclusters'` (or the hook variant) in a subproject's build.gradle, or a convention plugin that applies it unconditionally to every project including non-root ones.

Common situations: A convention plugin authored without a root-project guard; applying the plugin inside a `subprojects { ... }` block; manually wiring it during plugin migration.

Related errors


AI-assisted analysis of elastic/elasticsearch@db6a809a66 (2026-08-12). Data as JSON: /api/errors/572da79c04b41d11. Report an issue: GitHub.