Skip to content

Commit 04aa732

Browse files
authored
Add support in plugins for a separate spi classloader (#76288) (#76364)
SPI is how plugins provide extension points for other plugins to customize their behavior. Plugins may have a separate SPI jar, so as not to expose the internals of the plugin implementation. In essense, this system was built as a stopgap for Java modules, where implementation can be protected in the same jar. However, currently the plugins service loads both spi and plugin implementations into the same classloader. This means that although another plugin may only compile against spi, they could still get jar hell from a duplicate dependency. This commit adds support for plugins to contain an "spi" subdirectory which is loaded into a separate classloader. This spi classloader is a parent to the plugin implementation, as well as any other plugins which have extended it. Additionally as a demonstration of how this works in practice, lang-painless is setup as the first plugin to provide an spi jar, and sql's dependence on painless is changed to only spi, so that it now has an independent version of antlr. closes #74448
1 parent 9b5b822 commit 04aa732

File tree

15 files changed

+188
-32
lines changed

15 files changed

+188
-32
lines changed

modules/lang-painless/build.gradle

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ testClusters.all {
2222
systemProperty 'es.transport.cname_in_publish_address', 'true'
2323
}
2424

25+
configurations {
26+
spi
27+
compileOnlyApi.extendsFrom(spi)
28+
}
29+
2530
dependencies {
2631
api 'org.antlr:antlr4-runtime:4.5.3'
2732
api 'org.ow2.asm:asm-util:7.2'
2833
api 'org.ow2.asm:asm-tree:7.2'
2934
api 'org.ow2.asm:asm-commons:7.2'
3035
api 'org.ow2.asm:asm-analysis:7.2'
3136
api 'org.ow2.asm:asm:7.2'
32-
api project('spi')
37+
spi project('spi')
3338
}
3439

3540
tasks.named("dependencyLicenses").configure {
@@ -52,7 +57,7 @@ tasks.named("test").configure {
5257
* Painless plugin */
5358
tasks.register("apiJavadoc", Javadoc) {
5459
source = sourceSets.main.allJava
55-
classpath = sourceSets.main.runtimeClasspath
60+
classpath = sourceSets.main.runtimeClasspath + configurations.spi.classpath
5661
include '**/org/elasticsearch/painless/api/'
5762
destinationDir = new File(docsDir, 'apiJavadoc')
5863
}
@@ -65,6 +70,13 @@ tasks.register("apiJavadocJar", Jar) {
6570
tasks.named("assemble").configure {
6671
dependsOn "apiJavadocJar"
6772
}
73+
74+
tasks.named("bundlePlugin").configure {
75+
it.into("spi") {
76+
from(configurations.spi)
77+
}
78+
}
79+
6880
/**********************************************
6981
* Context API Generation *
7082
**********************************************/

modules/lang-painless/spi/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ group = 'org.elasticsearch.plugin'
1313
archivesBaseName = 'elasticsearch-scripting-painless-spi'
1414

1515
dependencies {
16-
api project(":server")
16+
compileOnly project(":server")
1717
testImplementation project(":test:framework")
1818
}

modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessPlugin.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,7 @@ public void loadExtensions(ExtensionLoader loader) {
200200
loader.loadExtensions(PainlessExtension.class).stream()
201201
.flatMap(extension -> extension.getContextWhitelists().entrySet().stream())
202202
.forEach(entry -> {
203-
List<Whitelist> existing = whitelists.computeIfAbsent(entry.getKey(),
204-
c -> new ArrayList<>(BASE_WHITELISTS));
203+
List<Whitelist> existing = whitelists.computeIfAbsent(entry.getKey(), c -> new ArrayList<>());
205204
existing.addAll(entry.getValue());
206205
});
207206
}

modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.painless.api.txt

Whitespace-only changes.

server/src/main/java/org/elasticsearch/bootstrap/PolicyUtil.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,19 @@ static PluginPolicyInfo readPolicyInfo(Path pluginRoot) throws IOException {
294294
}
295295
}
296296
}
297+
// also add spi jars
298+
// TODO: move this to a shared function, or fix plugin layout to have jar files in lib directory
299+
Path spiDir = pluginRoot.resolve("spi");
300+
if (Files.exists(spiDir)) {
301+
try (DirectoryStream<Path> jarStream = Files.newDirectoryStream(spiDir, "*.jar")) {
302+
for (Path jar : jarStream) {
303+
URL url = jar.toRealPath().toUri().toURL();
304+
if (jars.add(url) == false) {
305+
throw new IllegalStateException("duplicate module/plugin: " + url);
306+
}
307+
}
308+
}
309+
}
297310

298311
// parse the plugin's policy file into a set of permissions
299312
Policy policy = readPolicy(policyFile.toUri().toURL(), getCodebaseJarMap(jars));

server/src/main/java/org/elasticsearch/plugins/PluginsService.java

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,24 @@ public PluginsAndModules info() {
275275
static class Bundle {
276276
final PluginInfo plugin;
277277
final Set<URL> urls;
278+
final Set<URL> spiUrls;
279+
final Set<URL> allUrls;
278280

279281
Bundle(PluginInfo plugin, Path dir) throws IOException {
280282
this.plugin = Objects.requireNonNull(plugin);
283+
284+
Path spiDir = dir.resolve("spi");
285+
// plugin has defined an explicit api for extension
286+
this.spiUrls = Files.exists(spiDir) ? gatherUrls(spiDir) : null;
287+
this.urls = gatherUrls(dir);
288+
Set<URL> allUrls = new LinkedHashSet<>(urls);
289+
if (spiUrls != null) {
290+
allUrls.addAll(spiUrls);
291+
}
292+
this.allUrls = allUrls;
293+
}
294+
295+
static Set<URL> gatherUrls(Path dir) throws IOException {
281296
Set<URL> urls = new LinkedHashSet<>();
282297
// gather urls for jar files
283298
try (DirectoryStream<Path> jarStream = Files.newDirectoryStream(dir, "*.jar")) {
@@ -289,7 +304,14 @@ static class Bundle {
289304
}
290305
}
291306
}
292-
this.urls = Objects.requireNonNull(urls);
307+
return urls;
308+
}
309+
310+
Set<URL> getExtensionUrls() {
311+
if (spiUrls != null) {
312+
return spiUrls;
313+
}
314+
return urls;
293315
}
294316

295317
@Override
@@ -464,7 +486,7 @@ private static void addSortedBundle(Bundle bundle, Map<String, Bundle> bundles,
464486

465487
private List<Tuple<PluginInfo,Plugin>> loadBundles(Set<Bundle> bundles) {
466488
List<Tuple<PluginInfo, Plugin>> plugins = new ArrayList<>();
467-
Map<String, Plugin> loaded = new HashMap<>();
489+
Map<String, Tuple<Plugin, ClassLoader>> loaded = new HashMap<>();
468490
Map<String, Set<URL>> transitiveUrls = new HashMap<>();
469491
List<Bundle> sortedBundles = sortBundles(bundles);
470492
for (Bundle bundle : sortedBundles) {
@@ -570,42 +592,51 @@ static void checkBundleJarHell(Set<URL> classpath, Bundle bundle, Map<String, Se
570592

571593
try {
572594
final Logger logger = LogManager.getLogger(JarHell.class);
573-
Set<URL> urls = new HashSet<>();
595+
Set<URL> extendedPluginUrls = new HashSet<>();
574596
for (String extendedPlugin : exts) {
575597
Set<URL> pluginUrls = transitiveUrls.get(extendedPlugin);
576598
assert pluginUrls != null : "transitive urls should have already been set for " + extendedPlugin;
577599

578-
Set<URL> intersection = new HashSet<>(urls);
600+
// consistency check: extended plugins should not have duplicate codebases with each other
601+
Set<URL> intersection = new HashSet<>(extendedPluginUrls);
579602
intersection.retainAll(pluginUrls);
580603
if (intersection.isEmpty() == false) {
581604
throw new IllegalStateException("jar hell! extended plugins " + exts +
582605
" have duplicate codebases with each other: " + intersection);
583606
}
584607

585-
intersection = new HashSet<>(bundle.urls);
608+
// jar hell check: extended plugins (so far) do not have jar hell with each other
609+
extendedPluginUrls.addAll(pluginUrls);
610+
JarHell.checkJarHell(extendedPluginUrls, logger::debug);
611+
612+
// consistency check: each extended plugin should not have duplicate codebases with implementation+spi of this plugin
613+
intersection = new HashSet<>(bundle.allUrls);
586614
intersection.retainAll(pluginUrls);
587615
if (intersection.isEmpty() == false) {
588616
throw new IllegalStateException("jar hell! duplicate codebases with extended plugin [" +
589-
extendedPlugin + "]: " + intersection);
617+
extendedPlugin + "]: " + intersection);
590618
}
591619

592-
urls.addAll(pluginUrls);
593-
JarHell.checkJarHell(urls, logger::debug); // check jarhell as we add each extended plugin's urls
620+
// jar hell check: extended plugins (so far) do not have jar hell with implementation+spi of this plugin
621+
Set<URL> implementation = new HashSet<>(bundle.allUrls);
622+
implementation.addAll(extendedPluginUrls);
623+
JarHell.checkJarHell(implementation, logger::debug);
594624
}
595625

596-
urls.addAll(bundle.urls);
597-
JarHell.checkJarHell(urls, logger::debug); // check jarhell of each extended plugin against this plugin
598-
transitiveUrls.put(bundle.plugin.getName(), urls);
626+
// Set transitive urls for other plugins to extend this plugin. Note that jarhell has already been checked above.
627+
// This uses the extension urls (spi if set) since the implementation will not be in the transitive classpath at runtime.
628+
extendedPluginUrls.addAll(bundle.getExtensionUrls());
629+
transitiveUrls.put(bundle.plugin.getName(), extendedPluginUrls);
599630

600631
// check we don't have conflicting codebases with core
601632
Set<URL> intersection = new HashSet<>(classpath);
602-
intersection.retainAll(bundle.urls);
633+
intersection.retainAll(bundle.allUrls);
603634
if (intersection.isEmpty() == false) {
604635
throw new IllegalStateException("jar hell! duplicate codebases between plugin and core: " + intersection);
605636
}
606637
// check we don't have conflicting classes
607638
Set<URL> union = new HashSet<>(classpath);
608-
union.addAll(bundle.urls);
639+
union.addAll(bundle.allUrls);
609640
JarHell.checkJarHell(union, logger::debug);
610641
} catch (final IllegalStateException ise) {
611642
throw new IllegalStateException("failed to load plugin " + bundle.plugin.getName() + " due to jar hell", ise);
@@ -614,25 +645,33 @@ static void checkBundleJarHell(Set<URL> classpath, Bundle bundle, Map<String, Se
614645
}
615646
}
616647

617-
private Plugin loadBundle(Bundle bundle, Map<String, Plugin> loaded) {
648+
private Plugin loadBundle(Bundle bundle, Map<String, Tuple<Plugin, ClassLoader>> loaded) {
618649
String name = bundle.plugin.getName();
619650

620651
verifyCompatibility(bundle.plugin);
621652

622653
// collect loaders of extended plugins
623654
List<ClassLoader> extendedLoaders = new ArrayList<>();
624655
for (String extendedPluginName : bundle.plugin.getExtendedPlugins()) {
625-
Plugin extendedPlugin = loaded.get(extendedPluginName);
656+
Tuple<Plugin, ClassLoader> extendedPlugin = loaded.get(extendedPluginName);
626657
assert extendedPlugin != null;
627-
if (ExtensiblePlugin.class.isInstance(extendedPlugin) == false) {
658+
if (ExtensiblePlugin.class.isInstance(extendedPlugin.v1()) == false) {
628659
throw new IllegalStateException("Plugin [" + name + "] cannot extend non-extensible plugin [" + extendedPluginName + "]");
629660
}
630-
extendedLoaders.add(extendedPlugin.getClass().getClassLoader());
661+
extendedLoaders.add(extendedPlugin.v2());
631662
}
632663

633-
// create a child to load the plugin in this bundle
634664
ClassLoader parentLoader = PluginLoaderIndirection.createLoader(getClass().getClassLoader(), extendedLoaders);
635-
ClassLoader loader = URLClassLoader.newInstance(bundle.urls.toArray(new URL[0]), parentLoader);
665+
ClassLoader spiLoader = null;
666+
if (bundle.spiUrls != null) {
667+
spiLoader = URLClassLoader.newInstance(bundle.spiUrls.toArray(new URL[0]), parentLoader);
668+
}
669+
670+
ClassLoader loader = URLClassLoader.newInstance(bundle.urls.toArray(new URL[0]), spiLoader == null ? parentLoader : spiLoader);
671+
if (spiLoader == null) {
672+
// use full implementation for plugins extending this one
673+
spiLoader = loader;
674+
}
636675

637676
// reload SPI with any new services from the plugin
638677
reloadLuceneSPI(loader);
@@ -654,7 +693,7 @@ private Plugin loadBundle(Bundle bundle, Map<String, Plugin> loaded) {
654693
+ "] (class loader [" + pluginClass.getClassLoader() + "])");
655694
}
656695
Plugin plugin = loadPlugin(pluginClass, settings, configPath);
657-
loaded.put(name, plugin);
696+
loaded.put(name, Tuple.tuple(plugin, spiLoader));
658697
return plugin;
659698
} finally {
660699
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {

server/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,50 @@ public void testJarHellTransitiveMap() throws Exception {
614614
assertThat(deps, containsInAnyOrder(pluginJar.toUri().toURL(), dep1Jar.toUri().toURL(), dep2Jar.toUri().toURL()));
615615
}
616616

617+
public void testJarHellSpiAddedToTransitiveDeps() throws Exception {
618+
Path pluginDir = createTempDir();
619+
Path pluginJar = pluginDir.resolve("plugin.jar");
620+
makeJar(pluginJar, DummyClass2.class);
621+
Path spiDir = pluginDir.resolve("spi");
622+
Files.createDirectories(spiDir);
623+
Path spiJar = spiDir.resolve("spi.jar");
624+
makeJar(spiJar, DummyClass3.class);
625+
Path depDir = createTempDir();
626+
Path depJar = depDir.resolve("dep.jar");
627+
makeJar(depJar, DummyClass1.class);
628+
Map<String, Set<URL>> transitiveDeps = new HashMap<>();
629+
transitiveDeps.put("dep", Collections.singleton(depJar.toUri().toURL()));
630+
PluginInfo info1 = new PluginInfo("myplugin", "desc", "1.0", Version.CURRENT, "1.8",
631+
"MyPlugin", Collections.singletonList("dep"), false, PluginType.ISOLATED, "", false);
632+
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
633+
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps);
634+
Set<URL> transitive = transitiveDeps.get("myplugin");
635+
assertThat(transitive, containsInAnyOrder(spiJar.toUri().toURL(), depJar.toUri().toURL()));
636+
}
637+
638+
public void testJarHellSpiConflict() throws Exception {
639+
Path pluginDir = createTempDir();
640+
Path pluginJar = pluginDir.resolve("plugin.jar");
641+
makeJar(pluginJar, DummyClass2.class);
642+
Path spiDir = pluginDir.resolve("spi");
643+
Files.createDirectories(spiDir);
644+
Path spiJar = spiDir.resolve("spi.jar");
645+
makeJar(spiJar, DummyClass1.class);
646+
Path depDir = createTempDir();
647+
Path depJar = depDir.resolve("dep.jar");
648+
makeJar(depJar, DummyClass1.class);
649+
Map<String, Set<URL>> transitiveDeps = new HashMap<>();
650+
transitiveDeps.put("dep", Collections.singleton(depJar.toUri().toURL()));
651+
PluginInfo info1 = new PluginInfo("myplugin", "desc", "1.0", Version.CURRENT, "1.8",
652+
"MyPlugin", Collections.singletonList("dep"), false, PluginType.ISOLATED, "", false);
653+
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
654+
IllegalStateException e = expectThrows(IllegalStateException.class, () ->
655+
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps));
656+
assertEquals("failed to load plugin myplugin due to jar hell", e.getMessage());
657+
assertThat(e.getCause().getMessage(), containsString("jar hell!"));
658+
assertThat(e.getCause().getMessage(), containsString("DummyClass1"));
659+
}
660+
617661
public void testNonExtensibleDep() throws Exception {
618662
// This test opens a child classloader, reading a jar under the test temp
619663
// dir (a dummy plugin). Classloaders are closed by GC, so when test teardown

x-pack/plugin/eql/build.gradle

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ archivesBaseName = 'x-pack-eql'
1717

1818
dependencies {
1919
compileOnly project(path: xpackModule('core'))
20-
compileOnly(project(':modules:lang-painless')) {
21-
exclude group: "org.ow2.asm"
22-
}
20+
compileOnly(project(':modules:lang-painless:spi'))
2321
api "org.antlr:antlr4-runtime:${antlrVersion}"
2422
compileOnly project(xpackModule('ql'))
2523

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2609e36f18f7e8d593cc1cddfb2ac776dc96b8e0
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[The "BSD license"]
2+
Copyright (c) 2015 Terence Parr, Sam Harwell
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions
7+
are met:
8+
9+
1. Redistributions of source code must retain the above copyright
10+
notice, this list of conditions and the following disclaimer.
11+
2. Redistributions in binary form must reproduce the above copyright
12+
notice, this list of conditions and the following disclaimer in the
13+
documentation and/or other materials provided with the distribution.
14+
3. The name of the author may not be used to endorse or promote products
15+
derived from this software without specific prior written permission.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
18+
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
19+
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
20+
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
21+
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
22+
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
26+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

x-pack/plugin/eql/licenses/antlr4-runtime-NOTICE.txt

Whitespace-only changes.

x-pack/plugin/sql/build.gradle

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ archivesBaseName = 'x-pack-sql'
3030

3131
dependencies {
3232
compileOnly project(path: xpackModule('core'))
33-
compileOnly(project(':modules:lang-painless')) {
34-
// exclude ASM to not affect featureAware task on Java 10+
35-
exclude group: "org.ow2.asm"
36-
}
33+
compileOnly(project(':modules:lang-painless:spi'))
3734
api project('sql-action')
3835
api project(':modules:aggs-matrix-stats')
3936
api "org.antlr:antlr4-runtime:${antlrVersion}"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2609e36f18f7e8d593cc1cddfb2ac776dc96b8e0
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[The "BSD license"]
2+
Copyright (c) 2015 Terence Parr, Sam Harwell
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions
7+
are met:
8+
9+
1. Redistributions of source code must retain the above copyright
10+
notice, this list of conditions and the following disclaimer.
11+
2. Redistributions in binary form must reproduce the above copyright
12+
notice, this list of conditions and the following disclaimer in the
13+
documentation and/or other materials provided with the distribution.
14+
3. The name of the author may not be used to endorse or promote products
15+
derived from this software without specific prior written permission.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
18+
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
19+
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
20+
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
21+
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
22+
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
26+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

x-pack/plugin/sql/licenses/antlr4-runtime-NOTICE.txt

Whitespace-only changes.

0 commit comments

Comments
 (0)