diff --git a/solr/core/src/java/org/apache/solr/core/NodeConfig.java b/solr/core/src/java/org/apache/solr/core/NodeConfig.java index ad69e6d90f4b..9bc1e3951154 100644 --- a/solr/core/src/java/org/apache/solr/core/NodeConfig.java +++ b/solr/core/src/java/org/apache/solr/core/NodeConfig.java @@ -121,6 +121,8 @@ public class NodeConfig { private final String defaultZkHost; + private final String implicitPluginsFile; + private NodeConfig( String nodeName, Path coreRootDirectory, @@ -153,6 +155,7 @@ private NodeConfig( PluginInfo tracerConfig, PluginInfo[] clusterPlugins, String defaultZkHost, + String implicitPluginsFile, Set allowPaths, List allowUrls, boolean hideStackTraces, @@ -191,6 +194,7 @@ private NodeConfig( this.tracerConfig = tracerConfig; this.clusterPlugins = clusterPlugins; this.defaultZkHost = defaultZkHost; + this.implicitPluginsFile = implicitPluginsFile; this.allowPaths = allowPaths; this.allowUrls = allowUrls; this.hideStackTraces = hideStackTraces; @@ -372,6 +376,10 @@ public String getConfigSetsHandlerClass() { return configSetsHandlerClass; } + public String getImplicitPluginsFile() { + return implicitPluginsFile; + } + public boolean hasSchemaCache() { return useSchemaCache; } @@ -599,6 +607,7 @@ public static class NodeConfigBuilder { private PluginInfo tracerConfig; private PluginInfo[] clusterPlugins; private String defaultZkHost; + private String implicitPluginsFile; private Set allowPaths = Collections.emptySet(); private List allowUrls = Collections.emptyList(); private boolean hideStackTrace = @@ -760,6 +769,11 @@ public NodeConfigBuilder setUseSchemaCache(boolean useSchemaCache) { return this; } + public NodeConfigBuilder setImplicitPluginsFile(String implicitPluginsFile) { + this.implicitPluginsFile = implicitPluginsFile; + return this; + } + public NodeConfigBuilder setSolrProperties(Properties solrProperties) { this.solrProperties = solrProperties; return this; @@ -894,6 +908,7 @@ public NodeConfig build() { tracerConfig, clusterPlugins, defaultZkHost, + implicitPluginsFile, allowPaths, allowUrls, hideStackTrace, diff --git a/solr/core/src/java/org/apache/solr/core/RequestHandlers.java b/solr/core/src/java/org/apache/solr/core/RequestHandlers.java index dca7c832e3fa..d4ad837aeed1 100644 --- a/solr/core/src/java/org/apache/solr/core/RequestHandlers.java +++ b/solr/core/src/java/org/apache/solr/core/RequestHandlers.java @@ -106,7 +106,7 @@ void initHandlersFromConfig(SolrConfig config) { List implicits = core.getImplicitHandlers(); // use link map so we iterate in the same order Map infoMap = new LinkedHashMap<>(); - // deduping implicit and explicit requesthandlers + // deduping implicit and explicit request handlers for (PluginInfo info : implicits) infoMap.put(info.name, info); for (PluginInfo info : config.getPluginInfos(SolrRequestHandler.class.getName())) infoMap.put(info.name, info); diff --git a/solr/core/src/java/org/apache/solr/core/SolrCore.java b/solr/core/src/java/org/apache/solr/core/SolrCore.java index 24c7dc83b0c9..429ae3fc54d7 100644 --- a/solr/core/src/java/org/apache/solr/core/SolrCore.java +++ b/solr/core/src/java/org/apache/solr/core/SolrCore.java @@ -3614,32 +3614,114 @@ public void cleanupOldIndexDirectories(boolean reload) { } } + /** + * Parses implicit plugin definitions from a JSON map and converts them to PluginInfo objects. + * + * @param implicitPluginsInfo the parsed JSON map containing plugin definitions + * @return an unmodifiable list of PluginInfo objects for request handlers + * @throws NullPointerException if requestHandlers section is missing + */ + static List parseImplicitPlugins(Map implicitPluginsInfo) { + @SuppressWarnings("unchecked") + Map> requestHandlers = + (Map>) implicitPluginsInfo.get(SolrRequestHandler.TYPE); + + if (requestHandlers == null) { + throw new IllegalArgumentException("No requestHandler section found in implicit plugins"); + } + + List implicits = new ArrayList<>(requestHandlers.size()); + for (Map.Entry> entry : requestHandlers.entrySet()) { + Map info = entry.getValue(); + info.put(CommonParams.NAME, entry.getKey()); + implicits.add(new PluginInfo(SolrRequestHandler.TYPE, info)); + } + return Collections.unmodifiableList(implicits); + } + private static final class ImplicitHolder { private ImplicitHolder() {} - private static final List INSTANCE; + private static volatile List INSTANCE = null; + + static List getInstance(SolrCore core) { + if (INSTANCE == null) { + synchronized (ImplicitHolder.class) { + if (INSTANCE == null) { + INSTANCE = loadImplicitPlugins(core); + } + } + } + return INSTANCE; + } + + private static List loadImplicitPlugins(SolrCore core) { + // Check for custom implicit plugins file from solr.xml (global configuration) + String customPluginsFile = core.getCoreContainer().getConfig().getImplicitPluginsFile(); + + if (customPluginsFile != null && !customPluginsFile.isEmpty()) { + try { + // Resolve path similar to solr.xml - support both absolute and relative paths + Path customPluginsPath = Path.of(customPluginsFile); + core.getCoreContainer().assertPathAllowed(customPluginsPath); + if (!customPluginsPath.isAbsolute()) { + // Resolve relative paths against SOLR_HOME + Path solrHome = core.getCoreContainer().getSolrHome(); + customPluginsPath = solrHome.resolve(customPluginsFile); + } + + if (!Files.exists(customPluginsPath)) { + log.warn( + "Custom implicit plugins file does not exist: {} (from solr.xml). Falling back to default.", + customPluginsPath); + } else { + if (log.isInfoEnabled()) { + log.info( + "Loading custom implicit plugins from {} (configured in solr.xml)", + customPluginsPath); + } + + // Load the custom plugins file directly from the filesystem + try (InputStream is = Files.newInputStream(customPluginsPath)) { + @SuppressWarnings("unchecked") + Map implicitPluginsInfo = (Map) Utils.fromJSON(is); + List customHandlers = parseImplicitPlugins(implicitPluginsInfo); + + if (log.isInfoEnabled()) { + log.info( + "Loaded {} custom implicit handlers from {}", + customHandlers.size(), + customPluginsPath); + } + return customHandlers; + } + } + } catch (NullPointerException e) { + log.warn( + "No requestHandler section found in custom implicit plugins file: {} (from solr.xml)", + customPluginsFile); + } catch (Exception e) { + log.warn( + "Failed to load custom implicit plugins file: {} (from solr.xml). Falling back to default.", + customPluginsFile, + e); + } + } - static { + // Fall back to default classpath resource + if (log.isInfoEnabled()) { + log.info("Loading default implicit plugins from classpath ImplicitPlugins.json"); + } @SuppressWarnings("unchecked") Map implicitPluginsInfo = (Map) Utils.fromJSONResource(SolrCore.class.getClassLoader(), "ImplicitPlugins.json"); - @SuppressWarnings("unchecked") - Map> requestHandlers = - (Map>) implicitPluginsInfo.get(SolrRequestHandler.TYPE); - - List implicits = new ArrayList<>(requestHandlers.size()); - for (Map.Entry> entry : requestHandlers.entrySet()) { - Map info = entry.getValue(); - info.put(CommonParams.NAME, entry.getKey()); - implicits.add(new PluginInfo(SolrRequestHandler.TYPE, info)); - } - INSTANCE = Collections.unmodifiableList(implicits); + return parseImplicitPlugins(implicitPluginsInfo); } } public List getImplicitHandlers() { - return ImplicitHolder.INSTANCE; + return ImplicitHolder.getInstance(this); } public CancellableQueryTracker getCancellableQueryTracker() { diff --git a/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java b/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java index 459b58f13c57..74e2bd421150 100644 --- a/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java +++ b/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java @@ -357,6 +357,9 @@ private static NodeConfig fillSolrSection(NodeConfig.NodeConfigBuilder builder, case "hiddenSysProps": builder.setHiddenSysProps(it.txt()); break; + case "implicitPluginsFile": + builder.setImplicitPluginsFile(it.txt()); + break; case "allowPaths": builder.setAllowPaths(separatePaths(it.txt())); break; diff --git a/solr/core/src/test-files/solr/custom-implicit-plugins.json b/solr/core/src/test-files/solr/custom-implicit-plugins.json new file mode 100644 index 000000000000..d44c2d73a117 --- /dev/null +++ b/solr/core/src/test-files/solr/custom-implicit-plugins.json @@ -0,0 +1,23 @@ +{ + "requestHandler": { + "/custom-update": { + "class": "solr.UpdateRequestHandler", + "defaults": { + "custom": "true" + } + }, + "/custom-select": { + "class": "solr.SearchHandler", + "defaults": { + "echoParams": "all" + } + }, + "/admin/ping": { + "class": "solr.PingRequestHandler", + "invariants": { + "echoParams": "all", + "q": "{!lucene}*:*" + } + } + } +} diff --git a/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java b/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java index 3e2d3589958d..480e89225a1d 100644 --- a/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java +++ b/solr/core/src/test/org/apache/solr/core/SolrCoreTest.java @@ -19,6 +19,10 @@ import io.opentelemetry.exporter.prometheus.PrometheusMetricReader; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -32,6 +36,7 @@ import org.apache.solr.common.SolrException; import org.apache.solr.common.util.ExecutorUtil; import org.apache.solr.common.util.SolrNamedThreadFactory; +import org.apache.solr.common.util.Utils; import org.apache.solr.handler.ReplicationHandler; import org.apache.solr.handler.RequestHandlerBase; import org.apache.solr.handler.component.QueryComponent; @@ -146,6 +151,62 @@ public void testImplicitPlugins() { assertEquals("wrong number of implicit handlers", ihCount, implicitHandlers.size()); } + @Test + public void testCustomImplicitPlugins() throws Exception { + // Test that the custom implicit plugins file can be loaded and parsed into PluginInfo objects + String customPluginsPath = TEST_HOME() + "/custom-implicit-plugins.json"; + Path pluginsFile = Paths.get(customPluginsPath); + + // Verify file exists + assertTrue( + "Custom implicit plugins file should exist: " + customPluginsPath, + Files.exists(pluginsFile)); + + // Load and parse the custom plugins file + try (InputStream is = Files.newInputStream(pluginsFile)) { + @SuppressWarnings("unchecked") + Map implicitPluginsInfo = (Map) Utils.fromJSON(is); + + assertNotNull("Should parse custom plugins JSON", implicitPluginsInfo); + + // Call parseImplicitPlugins to convert JSON to PluginInfo objects + List customHandlers = SolrCore.parseImplicitPlugins(implicitPluginsInfo); + + assertNotNull("Should return list of PluginInfo objects", customHandlers); + assertEquals("Should have 3 custom handlers", 3, customHandlers.size()); + + // Build a map for easy verification + Map pathToClassMap = new HashMap<>(customHandlers.size()); + for (PluginInfo handler : customHandlers) { + assertEquals( + "All handlers should be of type requestHandler", SolrRequestHandler.TYPE, handler.type); + pathToClassMap.put(handler.name, handler.className); + } + + // Verify custom handlers are present with correct classes + assertEquals( + "Custom update handler should be UpdateRequestHandler", + "solr.UpdateRequestHandler", + pathToClassMap.get("/custom-update")); + assertEquals( + "Custom select handler should be SearchHandler", + "solr.SearchHandler", + pathToClassMap.get("/custom-select")); + assertEquals( + "Custom ping handler should be PingRequestHandler", + "solr.PingRequestHandler", + pathToClassMap.get("/admin/ping")); + + // Verify default handlers are NOT present (since we're using custom file) + assertNull( + "Default /debug/dump should not be present with custom plugins", + pathToClassMap.get("/debug/dump")); + assertNull( + "Default /update should not be present with custom plugins", + pathToClassMap.get("/update")); + } + } + @Test public void testClose() { final CoreContainer cores = h.getCoreContainer(); diff --git a/solr/core/src/test/org/apache/solr/core/TestSolrXml.java b/solr/core/src/test/org/apache/solr/core/TestSolrXml.java index eeab65afbf1a..c130c2377e41 100644 --- a/solr/core/src/test/org/apache/solr/core/TestSolrXml.java +++ b/solr/core/src/test/org/apache/solr/core/TestSolrXml.java @@ -579,6 +579,23 @@ public void testFailAtConfigParseTimeWhenReplicaPlacementFactoryNameIsInvalid() thrown.getMessage()); } + public void testImplicitPluginsFile() throws IOException { + // Test that implicitPluginsFile configuration is read from solr.xml + String solrXml = + "custom-implicit-plugins.json"; + NodeConfig cfg = SolrXmlConfig.fromString(solrHome, solrXml); + + assertEquals("custom-implicit-plugins.json", cfg.getImplicitPluginsFile()); + } + + public void testImplicitPluginsFileNotSet() { + // Test that implicitPluginsFile is null when not configured + String solrXml = ""; + NodeConfig cfg = SolrXmlConfig.fromString(solrHome, solrXml); + + assertNull(cfg.getImplicitPluginsFile()); + } + public static class CS implements ClusterSingleton { @Override diff --git a/solr/packaging/test/test_start_solr.bats b/solr/packaging/test/test_start_solr.bats index 27f1fe9df036..1aacf615b7a6 100644 --- a/solr/packaging/test/test_start_solr.bats +++ b/solr/packaging/test/test_start_solr.bats @@ -58,8 +58,11 @@ teardown() { @test "check stop command doesn't hang" { # for start/stop/restart we parse the args separate from picking the command - # which means you don't get an error message for passing a start arg, like --jvm-opts to a stop commmand. + # which means you don't get an error message for passing a start arg, like --jvm-opts to a stop command. + # Pre-check + timeout || skip "timeout utility is not available" + # Set a timeout duration (in seconds) TIMEOUT_DURATION=2 @@ -107,3 +110,36 @@ teardown() { # Verify the techproducts configset was uploaded config_exists "techproducts" } + +@test "start with custom implicit plugins" { + # Create custom implicit plugins file inline + local custom_plugins_file="${SOLR_HOME}/custom-implicit-plugins.json" + + cat > "${custom_plugins_file}" <${solr.security.allow.urls:} ${solr.hideStackTrace:false} ${solr.searchThreads:0} + ${solr.implicitPluginsFile:} diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc index a1032f5ecf70..5d0b870eef58 100644 --- a/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc +++ b/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc @@ -38,6 +38,7 @@ The default `solr.xml` file is found in `$SOLR_TIP/server/solr/solr.xml` and loo ${solr.security.allow.paths:} ${solr.security.allow.urls:} ${solr.hideStackTrace:false} + ${solr.implicitPluginsFile} @@ -339,6 +340,16 @@ or via the `SOLR_HIDDEN_SYS_PROPS` environment variable. By default, Solr will hide all basicAuth, AWS, ZK or SSL secret sysProps. It will also hide any sysProp that contains "password" or "secret" in it. +`implicitPluginsFile`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: none +|=== ++ +The path to a custom implicit plugins file that lets you control exactly which endpoints are registered for all cores. If the path is relative, it is resolved against `$SOLR_HOME`; absolute paths are also supported. +By default Solr ships with a `ImplicitPlugins.json` file that describes all the request handlers that every core contains. + === The Element This element defines several parameters that relate so SolrCloud.