Переглянути джерело

[Refactor] Move classpath plugins to MockNode (#86914)

We only use classpath plugins in tests, so we should move code handling them to
our test framework. This PR introduces a new MockPluginsService in our test
framework. The MockPluginsService is constructed with a list of classpath
plugins that it will load by reflection. As before, these classpath plugins
won't have their own classloaders.

We have to construct the pluginsService early in the Node class constructor so
that MockNode methods can access it, but we have to do it after Node updates
its Settings argument. Therefore, instead of passing in classpath plugins as a
paramater, we are passing in a constructor function that takes a Settings
argument and returns a PluginsService. This lets us remove the classpath plugin
list from the production code entirely.

The MockPluginsService delegates almost everything to a real PluginsService,
overriding only the protected plugins() method and the info() method.

This commit also changes the PluginDescriptor.equals() method. We have long had
a todo comment for removing the version comparison and making two plugin
descriptors equal if and only if they have the same name. The original commit
that introduced version comparison between plugin descriptors was:

    abf9a866788b2fab855b50728670e2aa2dd42c5a

The change did not seem to have a specific purpose in that commit, and no tests
guaranteed the behavior.

Closes #86635
William Brafford 3 роки тому
батько
коміт
2c84b40b92

+ 1 - 2
benchmarks/src/main/java/org/elasticsearch/benchmark/script/ScriptScoreBenchmark.java

@@ -74,8 +74,7 @@ public class ScriptScoreBenchmark {
         Settings.EMPTY,
         null,
         null,
-        Path.of(System.getProperty("plugins.dir")),
-        List.of()
+        Path.of(System.getProperty("plugins.dir"))
     );
     private final ScriptModule scriptModule = new ScriptModule(Settings.EMPTY, pluginsService.filterPlugins(ScriptPlugin.class));
 

+ 9 - 10
server/src/main/java/org/elasticsearch/node/Node.java

@@ -292,21 +292,26 @@ public class Node implements Closeable {
     final NamedWriteableRegistry namedWriteableRegistry;
     final NamedXContentRegistry namedXContentRegistry;
 
+    /**
+     * Constructs a node
+     *
+     * @param environment         the initial environment for this node, which will be added to by plugins
+     */
     public Node(Environment environment) {
-        this(environment, Collections.emptyList(), true);
+        this(environment, PluginsService.getPluginsServiceCtor(environment), true);
     }
 
     /**
      * Constructs a node
      *
      * @param initialEnvironment         the initial environment for this node, which will be added to by plugins
-     * @param classpathPlugins           the plugins to be loaded from the classpath
+     * @param pluginServiceCtor          a function that takes a {@link Settings} object and returns a {@link PluginsService}
      * @param forbidPrivateIndexSettings whether or not private index settings are forbidden when creating an index; this is used in the
      *                                   test framework for tests that rely on being able to set private settings
      */
     protected Node(
         final Environment initialEnvironment,
-        Collection<Class<? extends Plugin>> classpathPlugins,
+        final Function<Settings, PluginsService> pluginServiceCtor,
         boolean forbidPrivateIndexSettings
     ) {
         final List<Closeable> resourcesToClose = new ArrayList<>(); // register everything we need to release in the case of an error
@@ -381,13 +386,7 @@ public class Node implements Closeable {
                 );
             }
 
-            this.pluginsService = new PluginsService(
-                tmpSettings,
-                initialEnvironment.configFile(),
-                initialEnvironment.modulesFile(),
-                initialEnvironment.pluginsFile(),
-                classpathPlugins
-            );
+            this.pluginsService = pluginServiceCtor.apply(tmpSettings);
             final Settings settings = mergePluginSettings(pluginsService.pluginMap(), tmpSettings);
 
             /*

+ 4 - 0
server/src/main/java/org/elasticsearch/plugins/PluginBundle.java

@@ -40,6 +40,10 @@ class PluginBundle {
         this.allUrls = allUrls;
     }
 
+    public PluginDescriptor pluginDescriptor() {
+        return this.plugin;
+    }
+
     static Set<URL> gatherUrls(Path dir) throws IOException {
         Set<URL> urls = new LinkedHashSet<>();
         // gather urls for jar files

+ 1 - 3
server/src/main/java/org/elasticsearch/plugins/PluginDescriptor.java

@@ -430,9 +430,7 @@ public class PluginDescriptor implements Writeable, ToXContentObject {
 
         PluginDescriptor that = (PluginDescriptor) o;
 
-        if (name.equals(that.name) == false) return false;
-        // TODO: since the plugins are unique by their directory name, this should only be a name check, version should not matter?
-        return Objects.equals(version, that.version);
+        return Objects.equals(name, that.name);
     }
 
     @Override

+ 45 - 69
server/src/main/java/org/elasticsearch/plugins/PluginsService.java

@@ -14,7 +14,6 @@ import org.apache.lucene.codecs.Codec;
 import org.apache.lucene.codecs.DocValuesFormat;
 import org.apache.lucene.codecs.PostingsFormat;
 import org.elasticsearch.ElasticsearchException;
-import org.elasticsearch.Version;
 import org.elasticsearch.action.admin.cluster.node.info.PluginsAndModules;
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.settings.Setting.Property;
@@ -23,6 +22,7 @@ import org.elasticsearch.common.util.set.Sets;
 import org.elasticsearch.core.PathUtils;
 import org.elasticsearch.core.SuppressForbidden;
 import org.elasticsearch.core.Tuple;
+import org.elasticsearch.env.Environment;
 import org.elasticsearch.jdk.JarHell;
 import org.elasticsearch.node.ReportingService;
 import org.elasticsearch.plugins.spi.SPIClassIterator;
@@ -105,77 +105,38 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
 
     /**
      * Constructs a new PluginService
-     * @param settings The settings of the system
+     *
+     * @param settings         The settings of the system
      * @param modulesDirectory The directory modules exist in, or null if modules should not be loaded from the filesystem
      * @param pluginsDirectory The directory plugins exist in, or null if plugins should not be loaded from the filesystem
-     * @param classpathPlugins Plugins that exist in the classpath which should be loaded
      */
-    public PluginsService(
-        Settings settings,
-        Path configPath,
-        Path modulesDirectory,
-        Path pluginsDirectory,
-        Collection<Class<? extends Plugin>> classpathPlugins
-    ) {
+    public PluginsService(Settings settings, Path configPath, Path modulesDirectory, Path pluginsDirectory) {
         this.settings = settings;
         this.configPath = configPath;
 
-        List<LoadedPlugin> pluginsLoaded = new ArrayList<>();
-        List<PluginDescriptor> pluginsList = new ArrayList<>();
-        // we need to build a List of plugins for checking mandatory plugins
-        final List<String> pluginsNames = new ArrayList<>();
-
-        // first we load plugins that are on the classpath. this is for tests
-        for (Class<? extends Plugin> pluginClass : classpathPlugins) {
-            Plugin plugin = loadPlugin(pluginClass, settings, configPath);
-            PluginDescriptor pluginDescriptor = new PluginDescriptor(
-                pluginClass.getName(),
-                "classpath plugin",
-                "NA",
-                Version.CURRENT,
-                "1.8",
-                pluginClass.getName(),
-                null,
-                Collections.emptyList(),
-                false,
-                PluginType.ISOLATED,
-                "",
-                false
-            );
-            if (logger.isTraceEnabled()) {
-                logger.trace("plugin loaded from classpath [{}]", pluginDescriptor);
-            }
-            pluginsLoaded.add(new LoadedPlugin(pluginDescriptor, plugin));
-            pluginsList.add(pluginDescriptor);
-            pluginsNames.add(pluginDescriptor.getName());
-        }
-
         Set<PluginBundle> seenBundles = new LinkedHashSet<>();
-        List<PluginDescriptor> modulesList = new ArrayList<>();
+
         // load modules
+        List<PluginDescriptor> modulesList = new ArrayList<>();
         if (modulesDirectory != null) {
             try {
                 Set<PluginBundle> modules = PluginsUtils.getModuleBundles(modulesDirectory);
-                for (PluginBundle bundle : modules) {
-                    modulesList.add(bundle.plugin);
-                }
+                modules.stream().map(PluginBundle::pluginDescriptor).forEach(modulesList::add);
                 seenBundles.addAll(modules);
             } catch (IOException ex) {
                 throw new IllegalStateException("Unable to initialize modules", ex);
             }
         }
 
-        // now, find all the ones that are in plugins/
+        // load plugins
+        List<PluginDescriptor> pluginsList = new ArrayList<>();
         if (pluginsDirectory != null) {
             try {
                 // TODO: remove this leniency, but tests bogusly rely on it
                 if (isAccessibleDirectory(pluginsDirectory, logger)) {
                     PluginsUtils.checkForFailedPluginRemovals(pluginsDirectory);
                     Set<PluginBundle> plugins = PluginsUtils.getPluginBundles(pluginsDirectory);
-                    for (final PluginBundle bundle : plugins) {
-                        pluginsList.add(bundle.plugin);
-                        pluginsNames.add(bundle.plugin.getName());
-                    }
+                    plugins.stream().map(PluginBundle::pluginDescriptor).forEach(pluginsList::add);
                     seenBundles.addAll(plugins);
                 }
             } catch (IOException ex) {
@@ -183,13 +144,13 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
             }
         }
 
-        Collection<LoadedPlugin> loaded = loadBundles(seenBundles);
-        pluginsLoaded.addAll(loaded);
-
         this.info = new PluginsAndModules(pluginsList, modulesList);
-        this.plugins = Collections.unmodifiableList(pluginsLoaded);
+        this.plugins = loadBundles(seenBundles);
 
-        checkMandatoryPlugins(pluginsNames, MANDATORY_SETTING.get(settings));
+        checkMandatoryPlugins(
+            pluginsList.stream().map(PluginDescriptor::getName).collect(Collectors.toSet()),
+            new HashSet<>(MANDATORY_SETTING.get(settings))
+        );
 
         // we don't log jars in lib/ we really shouldn't log modules,
         // but for now: just be transparent so we can debug any potential issues
@@ -198,12 +159,12 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
     }
 
     // package-private for testing
-    static void checkMandatoryPlugins(List<String> existingPlugins, List<String> mandatoryPlugins) {
+    static void checkMandatoryPlugins(Set<String> existingPlugins, Set<String> mandatoryPlugins) {
         if (mandatoryPlugins.isEmpty()) {
             return;
         }
 
-        Set<String> missingPlugins = Sets.difference(new HashSet<>(mandatoryPlugins), new HashSet<>(existingPlugins));
+        Set<String> missingPlugins = Sets.difference(mandatoryPlugins, existingPlugins);
         if (missingPlugins.isEmpty() == false) {
             final String message = "missing mandatory plugins ["
                 + String.join(", ", missingPlugins)
@@ -231,8 +192,8 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
      * @return A stream of results
      * @param <T> The generic type of the result
      */
-    public <T> Stream<T> map(Function<Plugin, T> function) {
-        return plugins.stream().map(LoadedPlugin::instance).map(function);
+    public final <T> Stream<T> map(Function<Plugin, T> function) {
+        return plugins().stream().map(LoadedPlugin::instance).map(function);
     }
 
     /**
@@ -241,24 +202,24 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
      * @return A stream of results
      * @param <T> The generic type of the collection
      */
-    public <T> Stream<T> flatMap(Function<Plugin, Collection<T>> function) {
-        return plugins.stream().map(LoadedPlugin::instance).flatMap(p -> function.apply(p).stream());
+    public final <T> Stream<T> flatMap(Function<Plugin, Collection<T>> function) {
+        return plugins().stream().map(LoadedPlugin::instance).flatMap(p -> function.apply(p).stream());
     }
 
     /**
      * Apply a consumer action to each plugin
      * @param consumer An action that consumes a plugin
      */
-    public void forEach(Consumer<Plugin> consumer) {
-        plugins.stream().map(LoadedPlugin::instance).forEach(consumer);
+    public final void forEach(Consumer<Plugin> consumer) {
+        plugins().stream().map(LoadedPlugin::instance).forEach(consumer);
     }
 
     /**
      * Sometimes we want the plugin name for error handling.
      * @return A map of plugin names to plugin instances.
      */
-    public Map<String, Plugin> pluginMap() {
-        return plugins.stream().collect(Collectors.toMap(p -> p.descriptor().getName(), LoadedPlugin::instance));
+    public final Map<String, Plugin> pluginMap() {
+        return plugins().stream().collect(Collectors.toMap(p -> p.descriptor().getName(), LoadedPlugin::instance));
     }
 
     /**
@@ -269,7 +230,11 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
         return info;
     }
 
-    private Collection<LoadedPlugin> loadBundles(Set<PluginBundle> bundles) {
+    protected List<LoadedPlugin> plugins() {
+        return this.plugins;
+    }
+
+    private List<LoadedPlugin> loadBundles(Set<PluginBundle> bundles) {
         Map<String, LoadedPlugin> loaded = new HashMap<>();
         Map<String, Set<URL>> transitiveUrls = new HashMap<>();
         List<PluginBundle> sortedBundles = PluginsUtils.sortBundles(bundles);
@@ -282,7 +247,7 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
         }
 
         loadExtensions(loaded.values());
-        return loaded.values();
+        return List.copyOf(loaded.values());
     }
 
     // package-private for test visibility
@@ -500,7 +465,8 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
         }
     }
 
-    private static Plugin loadPlugin(Class<? extends Plugin> pluginClass, Settings settings, Path configPath) {
+    // package-private for testing
+    static Plugin loadPlugin(Class<? extends Plugin> pluginClass, Settings settings, Path configPath) {
         final Constructor<?>[] constructors = pluginClass.getConstructors();
         if (constructors.length == 0) {
             throw new IllegalStateException("no public constructor for [" + pluginClass.getName() + "]");
@@ -543,8 +509,18 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
     }
 
     @SuppressWarnings("unchecked")
-    public <T> List<T> filterPlugins(Class<T> type) {
-        return plugins.stream().filter(x -> type.isAssignableFrom(x.instance().getClass())).map(p -> ((T) p.instance())).toList();
+    public final <T> List<T> filterPlugins(Class<T> type) {
+        return plugins().stream().filter(x -> type.isAssignableFrom(x.instance().getClass())).map(p -> ((T) p.instance())).toList();
+    }
+
+    /**
+     * Get a function that will take a {@link Settings} object and return a {@link PluginsService}.
+     * This function passes in an empty list of classpath plugins.
+     * @param environment The environment for the plugins service.
+     * @return A function for creating a plugins service.
+     */
+    public static Function<Settings, PluginsService> getPluginsServiceCtor(Environment environment) {
+        return settings -> new PluginsService(settings, environment.configFile(), environment.modulesFile(), environment.pluginsFile());
     }
 
     static final LayerAndLoader createPluginModuleLayer(PluginBundle bundle, ClassLoader parentLoader, List<ModuleLayer> parentLayers) {

+ 35 - 47
server/src/test/java/org/elasticsearch/plugins/PluginDescriptorTests.java

@@ -581,8 +581,8 @@ public class PluginDescriptorTests extends ESTestCase {
      * This is important because {@link PluginsUtils#getPluginBundles(Path)} will
      * use the hashcode to catch duplicate names
      */
-    public void testSameNameSameHash() {
-        PluginDescriptor info1 = new PluginDescriptor(
+    public void testPluginEqualityAndHash() {
+        PluginDescriptor descriptor1 = new PluginDescriptor(
             "c",
             "foo",
             "dummy",
@@ -596,56 +596,44 @@ public class PluginDescriptorTests extends ESTestCase {
             "-Dfoo=bar",
             randomBoolean()
         );
-        PluginDescriptor info2 = new PluginDescriptor(
-            info1.getName(),
-            randomValueOtherThan(info1.getDescription(), () -> randomAlphaOfLengthBetween(4, 12)),
-            randomValueOtherThan(info1.getVersion(), () -> randomAlphaOfLengthBetween(4, 12)),
-            info1.getElasticsearchVersion().previousMajor(),
-            randomValueOtherThan(info1.getJavaVersion(), () -> randomAlphaOfLengthBetween(4, 12)),
-            randomValueOtherThan(info1.getClassname(), () -> randomAlphaOfLengthBetween(4, 12)),
+        // everything but name is different from descriptor1
+        PluginDescriptor descriptor2 = new PluginDescriptor(
+            descriptor1.getName(),
+            randomValueOtherThan(descriptor1.getDescription(), () -> randomAlphaOfLengthBetween(4, 12)),
+            randomValueOtherThan(descriptor1.getVersion(), () -> randomAlphaOfLengthBetween(4, 12)),
+            descriptor1.getElasticsearchVersion().previousMajor(),
+            randomValueOtherThan(descriptor1.getJavaVersion(), () -> randomAlphaOfLengthBetween(4, 12)),
+            randomValueOtherThan(descriptor1.getClassname(), () -> randomAlphaOfLengthBetween(4, 12)),
             randomAlphaOfLength(6),
             Collections.singletonList(
-                randomValueOtherThanMany(v -> info1.getExtendedPlugins().contains(v), () -> randomAlphaOfLengthBetween(4, 12))
+                randomValueOtherThanMany(v -> descriptor1.getExtendedPlugins().contains(v), () -> randomAlphaOfLengthBetween(4, 12))
             ),
-            info1.hasNativeController() == false,
-            randomValueOtherThan(info1.getType(), () -> randomFrom(PluginType.values())),
-            randomValueOtherThan(info1.getJavaOpts(), () -> randomAlphaOfLengthBetween(4, 12)),
-            info1.isLicensed() == false
+            descriptor1.hasNativeController() == false,
+            randomValueOtherThan(descriptor1.getType(), () -> randomFrom(PluginType.values())),
+            randomValueOtherThan(descriptor1.getJavaOpts(), () -> randomAlphaOfLengthBetween(4, 12)),
+            descriptor1.isLicensed() == false
         );
-
-        assertThat(info1.hashCode(), equalTo(info2.hashCode()));
-    }
-
-    public void testDifferentNameDifferentHash() {
-        PluginDescriptor info1 = new PluginDescriptor(
-            "c",
-            "foo",
-            "dummy",
-            Version.CURRENT,
-            "1.8",
-            "dummyclass",
-            null,
-            Collections.singletonList("foo"),
-            randomBoolean(),
-            PluginType.ISOLATED,
-            "-Dfoo=bar",
-            randomBoolean()
-        );
-        PluginDescriptor info2 = new PluginDescriptor(
-            randomValueOtherThan(info1.getName(), () -> randomAlphaOfLengthBetween(4, 12)),
-            info1.getDescription(),
-            info1.getVersion(),
-            info1.getElasticsearchVersion(),
-            info1.getJavaVersion(),
-            info1.getClassname(),
-            info1.getModuleName().orElse(null),
-            info1.getExtendedPlugins(),
-            info1.hasNativeController(),
-            info1.getType(),
-            info1.getJavaOpts(),
-            info1.isLicensed()
+        // only name is different from descriptor1
+        PluginDescriptor descriptor3 = new PluginDescriptor(
+            randomValueOtherThan(descriptor1.getName(), () -> randomAlphaOfLengthBetween(4, 12)),
+            descriptor1.getDescription(),
+            descriptor1.getVersion(),
+            descriptor1.getElasticsearchVersion(),
+            descriptor1.getJavaVersion(),
+            descriptor1.getClassname(),
+            descriptor1.getModuleName().orElse(null),
+            descriptor1.getExtendedPlugins(),
+            descriptor1.hasNativeController(),
+            descriptor1.getType(),
+            descriptor1.getJavaOpts(),
+            descriptor1.isLicensed()
         );
 
-        assertThat(info1.hashCode(), not(equalTo(info2.hashCode())));
+        assertThat(descriptor1, equalTo(descriptor2));
+        assertThat(descriptor1.hashCode(), equalTo(descriptor2.hashCode()));
+
+        assertThat(descriptor1, not(equalTo(descriptor3)));
+        assertThat(descriptor1.hashCode(), not(equalTo(descriptor3.hashCode())));
     }
+
 }

+ 34 - 35
server/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java

@@ -24,11 +24,14 @@ import java.nio.file.FileSystemException;
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.hasToString;
@@ -41,39 +44,40 @@ public class PluginsServiceTests extends ESTestCase {
     public static class FilterablePlugin extends Plugin implements ScriptPlugin {}
 
     static PluginsService newPluginsService(Settings settings) {
-        return new PluginsService(settings, null, null, TestEnvironment.newEnvironment(settings).pluginsFile(), List.of());
+        return new PluginsService(settings, null, null, TestEnvironment.newEnvironment(settings).pluginsFile());
     }
 
-    static PluginsService newPluginsService(Settings settings, Class<? extends Plugin> classpathPlugin) {
-        return new PluginsService(settings, null, null, TestEnvironment.newEnvironment(settings).pluginsFile(), List.of(classpathPlugin));
-    }
-
-    static PluginsService newPluginsService(
-        Settings settings,
-        Class<? extends Plugin> classpathPlugin1,
-        Class<? extends Plugin> classpathPlugin2
-    ) {
-        return new PluginsService(
-            settings,
-            null,
-            null,
-            TestEnvironment.newEnvironment(settings).pluginsFile(),
-            List.of(classpathPlugin1, classpathPlugin2)
-        );
-    }
-
-    public void testFilterPlugins() {
+    static PluginsService newMockPluginsService(List<Class<? extends Plugin>> classpathPlugins) {
         Settings settings = Settings.builder()
             .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
             .put("my.setting", "test")
             .put(IndexModule.INDEX_STORE_TYPE_SETTING.getKey(), IndexModule.Type.NIOFS.getSettingsKey())
             .build();
-        PluginsService service = newPluginsService(settings, FakePlugin.class, FilterablePlugin.class);
+        return new MockPluginsService(settings, TestEnvironment.newEnvironment(settings), classpathPlugins);
+    }
+
+    // This test uses a mock in order to use plugins from the classpath
+    public void testFilterPlugins() {
+        PluginsService service = newMockPluginsService(List.of(FakePlugin.class, FilterablePlugin.class));
         List<ScriptPlugin> scriptPlugins = service.filterPlugins(ScriptPlugin.class);
         assertEquals(1, scriptPlugins.size());
         assertEquals(FilterablePlugin.class, scriptPlugins.get(0).getClass());
     }
 
+    // This test uses a mock in order to use plugins from the classpath
+    public void testMapPlugins() {
+        PluginsService service = newMockPluginsService(List.of(FakePlugin.class, FilterablePlugin.class));
+        List<String> mapResult = service.map(p -> p.getClass().getSimpleName()).toList();
+        assertThat(mapResult, containsInAnyOrder("FakePlugin", "FilterablePlugin"));
+
+        List<String> flatmapResult = service.flatMap(p -> List.of(p.getClass().getSimpleName())).toList();
+        assertThat(flatmapResult, containsInAnyOrder("FakePlugin", "FilterablePlugin"));
+
+        List<String> forEachConsumer = new ArrayList<>();
+        service.forEach(p -> forEachConsumer.add(p.getClass().getSimpleName()));
+        assertThat(forEachConsumer, containsInAnyOrder("FakePlugin", "FilterablePlugin"));
+    }
+
     public void testHiddenFiles() throws IOException {
         final Path home = createTempDir();
         final Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), home).build();
@@ -163,7 +167,7 @@ public class PluginsServiceTests extends ESTestCase {
         final Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), home).build();
         final IllegalStateException e = expectThrows(
             IllegalStateException.class,
-            () -> newPluginsService(settings, NoPublicConstructorPlugin.class)
+            () -> PluginsService.loadPlugin(NoPublicConstructorPlugin.class, settings, home)
         );
         assertThat(e, hasToString(containsString("no public constructor")));
     }
@@ -187,7 +191,7 @@ public class PluginsServiceTests extends ESTestCase {
         final Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), home).build();
         final IllegalStateException e = expectThrows(
             IllegalStateException.class,
-            () -> newPluginsService(settings, MultiplePublicConstructorsPlugin.class)
+            () -> PluginsService.loadPlugin(MultiplePublicConstructorsPlugin.class, settings, home)
         );
         assertThat(e, hasToString(containsString("no unique public constructor")));
     }
@@ -236,7 +240,10 @@ public class PluginsServiceTests extends ESTestCase {
         for (Class<? extends Plugin> pluginClass : classes) {
             final Path home = createTempDir();
             final Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), home).build();
-            final IllegalStateException e = expectThrows(IllegalStateException.class, () -> newPluginsService(settings, pluginClass));
+            final IllegalStateException e = expectThrows(
+                IllegalStateException.class,
+                () -> PluginsService.loadPlugin(pluginClass, settings, home)
+            );
             assertThat(e, hasToString(containsString("no public constructor of correct signature")));
         }
     }
@@ -296,24 +303,16 @@ public class PluginsServiceTests extends ESTestCase {
     }
 
     public void testPassingMandatoryPluginCheck() {
-        final Settings settings = Settings.builder()
-            .put("path.home", createTempDir())
-            .put("plugin.mandatory", "org.elasticsearch.plugins.PluginsServiceTests$FakePlugin")
-            .build();
         PluginsService.checkMandatoryPlugins(
-            List.of("org.elasticsearch.plugins.PluginsServiceTests$FakePlugin"),
-            List.of("org.elasticsearch.plugins.PluginsServiceTests$FakePlugin")
+            Set.of("org.elasticsearch.plugins.PluginsServiceTests$FakePlugin"),
+            Set.of("org.elasticsearch.plugins.PluginsServiceTests$FakePlugin")
         );
     }
 
     public void testFailingMandatoryPluginCheck() {
-        final Settings settings = Settings.builder()
-            .put("path.home", createTempDir())
-            .put("plugin.mandatory", "org.elasticsearch.plugins.PluginsServiceTests$FakePlugin")
-            .build();
         IllegalStateException e = expectThrows(
             IllegalStateException.class,
-            () -> PluginsService.checkMandatoryPlugins(List.of(), List.of("org.elasticsearch.plugins.PluginsServiceTests$FakePlugin"))
+            () -> PluginsService.checkMandatoryPlugins(Set.of(), Set.of("org.elasticsearch.plugins.PluginsServiceTests$FakePlugin"))
         );
         assertEquals(
             "missing mandatory plugins [org.elasticsearch.plugins.PluginsServiceTests$FakePlugin], found plugins []",

+ 3 - 1
test/framework/src/main/java/org/elasticsearch/node/MockNode.java

@@ -28,6 +28,7 @@ import org.elasticsearch.indices.ExecutorSelector;
 import org.elasticsearch.indices.IndicesService;
 import org.elasticsearch.indices.breaker.CircuitBreakerService;
 import org.elasticsearch.indices.recovery.RecoverySettings;
+import org.elasticsearch.plugins.MockPluginsService;
 import org.elasticsearch.plugins.Plugin;
 import org.elasticsearch.script.MockScriptService;
 import org.elasticsearch.script.ScriptContext;
@@ -58,6 +59,7 @@ import java.util.function.LongSupplier;
  * <ul>
  *   <li>Overriding Version.CURRENT</li>
  *   <li>Adding test plugins that exist on the classpath</li>
+ *   <li>Swapping in various mock services</li>
  * </ul>
  */
 public class MockNode extends Node {
@@ -99,7 +101,7 @@ public class MockNode extends Node {
         final Collection<Class<? extends Plugin>> classpathPlugins,
         final boolean forbidPrivateIndexSettings
     ) {
-        super(environment, classpathPlugins, forbidPrivateIndexSettings);
+        super(environment, settings -> new MockPluginsService(settings, environment, classpathPlugins), forbidPrivateIndexSettings);
         this.classpathPlugins = classpathPlugins;
     }
 

+ 78 - 0
test/framework/src/main/java/org/elasticsearch/plugins/MockPluginsService.java

@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.Version;
+import org.elasticsearch.action.admin.cluster.node.info.PluginsAndModules;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+public class MockPluginsService extends PluginsService {
+
+    private static final Logger logger = LogManager.getLogger(MockPluginsService.class);
+
+    private final List<LoadedPlugin> classpathPlugins;
+
+    /**
+     * Constructs a new PluginService
+     *
+     * @param settings         The settings of the system
+     * @param environment      The environment for the plugin
+     * @param classpathPlugins Plugins that exist in the classpath which should be loaded
+     */
+    public MockPluginsService(Settings settings, Environment environment, Collection<Class<? extends Plugin>> classpathPlugins) {
+        super(settings, environment.configFile(), environment.modulesFile(), environment.pluginsFile());
+
+        final Path configPath = environment.configFile();
+
+        List<LoadedPlugin> pluginsLoaded = new ArrayList<>();
+
+        for (Class<? extends Plugin> pluginClass : classpathPlugins) {
+            Plugin plugin = loadPlugin(pluginClass, settings, configPath);
+            PluginDescriptor pluginInfo = new PluginDescriptor(
+                pluginClass.getName(),
+                "classpath plugin",
+                "NA",
+                Version.CURRENT,
+                Integer.toString(Runtime.version().feature()),
+                pluginClass.getName(),
+                null,
+                Collections.emptyList(),
+                false,
+                PluginType.ISOLATED,
+                "",
+                false
+            );
+            if (logger.isTraceEnabled()) {
+                logger.trace("plugin loaded from classpath [{}]", pluginInfo);
+            }
+            pluginsLoaded.add(new LoadedPlugin(pluginInfo, plugin));
+        }
+
+        this.classpathPlugins = List.copyOf(pluginsLoaded);
+    }
+
+    @Override
+    protected final List<LoadedPlugin> plugins() {
+        return this.classpathPlugins;
+    }
+
+    @Override
+    public PluginsAndModules info() {
+        return new PluginsAndModules(this.classpathPlugins.stream().map(LoadedPlugin::descriptor).toList(), List.of());
+    }
+}

+ 2 - 1
test/framework/src/main/java/org/elasticsearch/test/AbstractBuilderTestCase.java

@@ -51,6 +51,7 @@ import org.elasticsearch.indices.breaker.NoneCircuitBreakerService;
 import org.elasticsearch.indices.fielddata.cache.IndicesFieldDataCache;
 import org.elasticsearch.node.InternalSettingsPreparer;
 import org.elasticsearch.plugins.MapperPlugin;
+import org.elasticsearch.plugins.MockPluginsService;
 import org.elasticsearch.plugins.Plugin;
 import org.elasticsearch.plugins.PluginsService;
 import org.elasticsearch.plugins.ScriptPlugin;
@@ -361,7 +362,7 @@ public abstract class AbstractBuilderTestCase extends ESTestCase {
                 () -> { throw new AssertionError("node.name must be set"); }
             );
             PluginsService pluginsService;
-            pluginsService = new PluginsService(nodeSettings, null, env.modulesFile(), env.pluginsFile(), plugins);
+            pluginsService = new MockPluginsService(nodeSettings, env, plugins);
 
             client = (Client) Proxy.newProxyInstance(
                 Client.class.getClassLoader(),

+ 93 - 0
test/framework/src/test/java/org/elasticsearch/plugins/MockPluginsServiceTests.java

@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins;
+
+import org.elasticsearch.action.admin.cluster.node.info.PluginsAndModules;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
+import org.elasticsearch.test.ESTestCase;
+import org.junit.Before;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.instanceOf;
+
+public class MockPluginsServiceTests extends ESTestCase {
+
+    public static class TestPlugin1 extends Plugin {
+
+        public TestPlugin1() {};
+
+        // for map/flatmap/foreach testing
+        @Override
+        public List<String> getSettingsFilter() {
+            return List.of("test value 1");
+        }
+
+    }
+
+    public static class TestPlugin2 extends Plugin {
+
+        public TestPlugin2() {};
+
+        // for map/flatmap/foreach testing
+        @Override
+        public List<String> getSettingsFilter() {
+            return List.of("test value 2");
+        }
+    }
+
+    private MockPluginsService mockPluginsService;
+
+    @Before
+    public void setup() {
+        List<Class<? extends Plugin>> classpathPlugins = List.of(TestPlugin1.class, TestPlugin2.class);
+        Settings pathHomeSetting = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()).build();
+        this.mockPluginsService = new MockPluginsService(
+            pathHomeSetting,
+            TestEnvironment.newEnvironment(pathHomeSetting),
+            classpathPlugins
+        );
+
+    }
+
+    public void testSuperclassMethods() {
+        List<List<String>> mapResult = mockPluginsService.map(Plugin::getSettingsFilter).toList();
+        assertThat(mapResult, containsInAnyOrder(List.of("test value 1"), List.of("test value 2")));
+
+        List<String> flatMapResult = mockPluginsService.flatMap(Plugin::getSettingsFilter).toList();
+        assertThat(flatMapResult, containsInAnyOrder("test value 1", "test value 2"));
+
+        List<String> forEachCollector = new ArrayList<>();
+        mockPluginsService.forEach(p -> forEachCollector.addAll(p.getSettingsFilter()));
+        assertThat(forEachCollector, containsInAnyOrder("test value 1", "test value 2"));
+
+        Map<String, Plugin> pluginMap = mockPluginsService.pluginMap();
+        assertThat(pluginMap.keySet(), containsInAnyOrder(containsString("TestPlugin1"), containsString("TestPlugin2")));
+
+        List<TestPlugin1> plugin1 = mockPluginsService.filterPlugins(TestPlugin1.class);
+        assertThat(plugin1, contains(instanceOf(TestPlugin1.class)));
+    }
+
+    public void testInfo() {
+        PluginsAndModules pam = this.mockPluginsService.info();
+
+        assertThat(pam.getModuleInfos(), empty());
+
+        List<String> pluginNames = pam.getPluginInfos().stream().map(PluginDescriptor::getName).toList();
+        assertThat(pluginNames, containsInAnyOrder(containsString("TestPlugin1"), containsString("TestPlugin2")));
+    }
+}