From ed83f9cc869cd11c61a8860862f46191b28514f6 Mon Sep 17 00:00:00 2001 From: Przemyslaw Gomulka Date: Mon, 9 Jan 2023 15:25:54 +0100 Subject: [PATCH] Refactor plugin scanning into lib (#92437) new stable plugins require generated named_components.json file which contains all analysis components implemented by this plugin. The generation is currently done in build-tools by elasticsearch.stable-esplugin However this makes the generation only available for plugins using gradle. Plugin developers using maven or other building tooling will not be able to use it. This commits refactors the scanning logic into libs:plugin-scanner which will allow for plugin install command to perform the scanning too. relates #88980 --- .../StablePluginBuildPluginFuncTest.groovy | 53 +++-- .../gradle/NamedComponentScannerMock.java | 23 ++ .../plugin/GenerateNamedComponentsTask.java | 81 +++---- .../plugin/StablePluginBuildPlugin.java | 12 + .../gradle/plugin/scanner/ClassReaders.java | 98 -------- .../plugin/scanner/NamedComponentScanner.java | 63 ----- .../AnnotatedHierarchyVisitorSpec.groovy | 91 ------- .../plugin/scanner/ClassReadersSpec.groovy | 196 ---------------- .../plugin/scanner/ClassScannerSpec.groovy | 78 ------ .../scanner/NamedComponentScannerSpec.groovy | 216 ----------------- .../gradle/fixtures/TestClasspathUtils.groovy | 39 ++- libs/build.gradle | 1 + .../plugin/api/package-info.java | 1 + libs/plugin-scanner/build.gradle | 34 +++ libs/plugin-scanner/licenses/asm-LICENSE.txt | 26 ++ libs/plugin-scanner/licenses/asm-NOTICE.txt | 1 + .../src/main/java/module-info.java | 16 ++ .../scanner/AnnotatedHierarchyVisitor.java | 2 +- .../plugin/scanner/ClassReaders.java | 124 ++++++++++ .../plugin/scanner/ClassScanner.java | 10 +- .../plugin/scanner/NamedComponentScanner.java | 102 ++++++++ .../AnnotatedHierarchyVisitorTests.java | 92 ++++++++ .../plugin/scanner/ClassReadersTests.java | 126 ++++++++++ .../plugin/scanner/ClassScannerTests.java | 54 +++++ .../scanner/NamedComponentScannerTests.java | 222 ++++++++++++++++++ .../scanner/test_model/ExtensibleClass.java | 14 ++ .../test_model/ExtensibleInterface.java | 14 ++ .../test_model/ImplementingExtensible.java | 11 + .../plugin/scanner/test_model/SubClass.java | 11 + .../test_model/TestNamedComponent.java | 14 ++ 30 files changed, 994 insertions(+), 831 deletions(-) create mode 100644 build-tools/src/main/java/org/elasticsearch/gradle/NamedComponentScannerMock.java delete mode 100644 build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassReaders.java delete mode 100644 build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/NamedComponentScanner.java delete mode 100644 build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitorSpec.groovy delete mode 100644 build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassReadersSpec.groovy delete mode 100644 build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassScannerSpec.groovy delete mode 100644 build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/NamedComponentScannerSpec.groovy create mode 100644 libs/plugin-scanner/build.gradle create mode 100644 libs/plugin-scanner/licenses/asm-LICENSE.txt create mode 100644 libs/plugin-scanner/licenses/asm-NOTICE.txt create mode 100644 libs/plugin-scanner/src/main/java/module-info.java rename {build-tools/src/main/java/org/elasticsearch/gradle => libs/plugin-scanner/src/main/java/org/elasticsearch}/plugin/scanner/AnnotatedHierarchyVisitor.java (98%) create mode 100644 libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassReaders.java rename {build-tools/src/main/java/org/elasticsearch/gradle => libs/plugin-scanner/src/main/java/org/elasticsearch}/plugin/scanner/ClassScanner.java (87%) create mode 100644 libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/NamedComponentScanner.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitorTests.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassReadersTests.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassScannerTests.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/NamedComponentScannerTests.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleClass.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleInterface.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ImplementingExtensible.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/SubClass.java create mode 100644 libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/TestNamedComponent.java diff --git a/build-tools/src/integTest/groovy/org/elasticsearch/gradle/plugin/StablePluginBuildPluginFuncTest.groovy b/build-tools/src/integTest/groovy/org/elasticsearch/gradle/plugin/StablePluginBuildPluginFuncTest.groovy index fc706497008a..561c4626285e 100644 --- a/build-tools/src/integTest/groovy/org/elasticsearch/gradle/plugin/StablePluginBuildPluginFuncTest.groovy +++ b/build-tools/src/integTest/groovy/org/elasticsearch/gradle/plugin/StablePluginBuildPluginFuncTest.groovy @@ -19,15 +19,11 @@ import java.nio.file.Files import java.nio.file.Path import java.util.stream.Collectors +import static org.elasticsearch.gradle.fixtures.TestClasspathUtils.setupNamedComponentScanner + class StablePluginBuildPluginFuncTest extends AbstractGradleFuncTest { def setup() { - // underlaying TestClusterPlugin and StandaloneRestIntegTestTask are not cc compatible - configurationCacheCompatible = false - } - - def "can build stable plugin properties"() { - given: buildFile << """plugins { id 'elasticsearch.stable-esplugin' } @@ -38,8 +34,27 @@ class StablePluginBuildPluginFuncTest extends AbstractGradleFuncTest { name = 'myplugin' description = 'test plugin' } + repositories { + maven { + name = "local-test" + url = file("local-repo") + metadataSources { + artifact() + } + } + } """ + // underlaying TestClusterPlugin and StandaloneRestIntegTestTask are not cc compatible + configurationCacheCompatible = false + + def version = VersionProperties.elasticsearch + setupNamedComponentScanner(dir("local-repo/org/elasticsearch/elasticsearch-plugin-scanner/${version}/"), version) + + } + + def "can build stable plugin properties"() { + given: when: def result = gradleRunner(":pluginProperties").build() def props = getPluginProperties() @@ -62,26 +77,16 @@ class StablePluginBuildPluginFuncTest extends AbstractGradleFuncTest { } def "can scan and create named components file"() { + //THIS IS RUNNING A MOCK CONFIGURED IN setup() given: File jarFolder = new File(testProjectDir.root, "jars") jarFolder.mkdirs() - buildFile << """plugins { - id 'elasticsearch.stable-esplugin' - } - - version = '1.2.3' - - esplugin { - name = 'myplugin' - description = 'test plugin' - } - + buildFile << """ dependencies { implementation files('${normalized(StableApiJarMocks.createPluginApiJar(jarFolder.toPath()).toAbsolutePath().toString())}') implementation files('${normalized(StableApiJarMocks.createExtensibleApiJar(jarFolder.toPath()).toAbsolutePath().toString())}') } - """ file("src/main/java/org/acme/A.java") << """ @@ -95,18 +100,16 @@ class StablePluginBuildPluginFuncTest extends AbstractGradleFuncTest { } """ - when: - def result = gradleRunner(":assemble").build() - Path namedComponents = file("build/generated-named-components/named_components.json").toPath(); - def map = new JsonSlurper().parse(namedComponents.toFile()) + def result = gradleRunner(":assemble", "-i").build() + then: result.task(":assemble").outcome == TaskOutcome.SUCCESS - - map == ["org.elasticsearch.plugin.scanner.test_classes.ExtensibleClass" : (["componentA" : "org.acme.A"]) ] + //we expect that a Fake namedcomponent scanner used in this test will be passed a filename to be created + File namedComponents = file("build/generated-named-components/named_components.json") + namedComponents.exists() == true } - Map getPluginProperties() { Path propsFile = file("build/generated-descriptor/stable-plugin-descriptor.properties").toPath(); Properties rawProps = new Properties() diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/NamedComponentScannerMock.java b/build-tools/src/main/java/org/elasticsearch/gradle/NamedComponentScannerMock.java new file mode 100644 index 000000000000..cfd3ab566b33 --- /dev/null +++ b/build-tools/src/main/java/org/elasticsearch/gradle/NamedComponentScannerMock.java @@ -0,0 +1,23 @@ +/* + * 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.gradle; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class NamedComponentScannerMock { + public static void main(String[] args) throws IOException { + // expect a file name to passed in as a parameter + // creating a file so that we can assert about this in a test + Path path = Path.of(args[0]); + Files.createDirectories(path.getParent()); + Files.createFile(path); + } +} diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GenerateNamedComponentsTask.java b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GenerateNamedComponentsTask.java index 9a44a5214899..7945bce426cc 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GenerateNamedComponentsTask.java +++ b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/GenerateNamedComponentsTask.java @@ -8,55 +8,60 @@ package org.elasticsearch.gradle.plugin; -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.elasticsearch.gradle.plugin.scanner.ClassReaders; -import org.elasticsearch.gradle.plugin.scanner.NamedComponentScanner; +import org.elasticsearch.gradle.LoggedExec; import org.gradle.api.DefaultTask; -import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.FileCollection; import org.gradle.api.file.ProjectLayout; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; -import org.gradle.api.model.ObjectFactory; import org.gradle.api.tasks.CompileClasspath; +import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.TaskAction; -import org.gradle.workers.WorkAction; -import org.gradle.workers.WorkParameters; +import org.gradle.process.ExecOperations; +import org.gradle.process.ExecResult; import org.gradle.workers.WorkerExecutor; -import org.objectweb.asm.ClassReader; import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import javax.inject.Inject; public abstract class GenerateNamedComponentsTask extends DefaultTask { private static final Logger LOGGER = Logging.getLogger(GenerateNamedComponentsTask.class); + private static final String NAMED_COMPONENTS_DIR = "generated-named-components/"; private static final String NAMED_COMPONENTS_FILE = "named_components.json"; + private static final String NAMED_COMPONENTS_PATH = NAMED_COMPONENTS_DIR + NAMED_COMPONENTS_FILE; private final WorkerExecutor workerExecutor; + private FileCollection pluginScannerClasspath; private FileCollection classpath; + private ExecOperations execOperations; + private ProjectLayout projectLayout; @Inject - public GenerateNamedComponentsTask(WorkerExecutor workerExecutor, ObjectFactory objectFactory, ProjectLayout projectLayout) { + public GenerateNamedComponentsTask(WorkerExecutor workerExecutor, ExecOperations execOperations, ProjectLayout projectLayout) { this.workerExecutor = workerExecutor; - getOutputFile().convention(projectLayout.getBuildDirectory().file("generated-named-components/" + NAMED_COMPONENTS_FILE)); + this.execOperations = execOperations; + this.projectLayout = projectLayout; + + getOutputFile().convention(projectLayout.getBuildDirectory().file(NAMED_COMPONENTS_PATH)); } @TaskAction public void scanPluginClasses() { - workerExecutor.noIsolation().submit(GenerateNamedComponentsAction.class, params -> { - params.getClasspath().from(classpath); - params.getOutputFile().set(getOutputFile()); + File outputFile = projectLayout.getBuildDirectory().file(NAMED_COMPONENTS_PATH).get().getAsFile(); + + ExecResult execResult = LoggedExec.javaexec(execOperations, spec -> { + spec.classpath(pluginScannerClasspath.plus(getClasspath()).getAsPath()); + spec.getMainClass().set("org.elasticsearch.plugin.scanner.NamedComponentScanner"); + spec.args(outputFile); + spec.setErrorOutput(System.err); + spec.setStandardOutput(System.out); }); + execResult.assertNormalExitValue(); } @OutputFile @@ -71,37 +76,13 @@ public abstract class GenerateNamedComponentsTask extends DefaultTask { this.classpath = classpath; } - public abstract static class GenerateNamedComponentsAction implements WorkAction { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - @Override - public void execute() { - Set classpathFiles = getParameters().getClasspath().getFiles(); - - List classReaders = ClassReaders.ofPaths(classpathFiles.stream().map(File::toPath)).collect(Collectors.toList()); - - NamedComponentScanner namedComponentScanner = new NamedComponentScanner(); - Map> namedComponentsMap = namedComponentScanner.scanForNamedClasses(classReaders); - writeToFile(namedComponentsMap); - } - - private void writeToFile(Map> namedComponentsMap) { - try { - String json = OBJECT_MAPPER.writeValueAsString(namedComponentsMap); - File file = getParameters().getOutputFile().getAsFile().get(); - Path of = Path.of(file.getAbsolutePath()); - Files.writeString(of, json); - } catch (Exception e) { - e.printStackTrace(); - } - - } + public void setPluginScannerClasspath(FileCollection pluginScannerClasspath) { + this.pluginScannerClasspath = pluginScannerClasspath; } - interface Parameters extends WorkParameters { - - ConfigurableFileCollection getClasspath(); - - RegularFileProperty getOutputFile(); + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public FileCollection getPluginScannerClasspath() { + return pluginScannerClasspath; } } diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/StablePluginBuildPlugin.java b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/StablePluginBuildPlugin.java index 80f65ea26fa8..ef2d1631d560 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/StablePluginBuildPlugin.java +++ b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/StablePluginBuildPlugin.java @@ -8,9 +8,12 @@ package org.elasticsearch.gradle.plugin; +import org.elasticsearch.gradle.VersionProperties; import org.elasticsearch.gradle.util.GradleUtils; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.dsl.DependencyHandler; import org.gradle.api.file.FileCollection; import org.gradle.api.file.RegularFile; import org.gradle.api.plugins.JavaPlugin; @@ -33,12 +36,21 @@ public class StablePluginBuildPlugin implements Plugin { }); final var pluginNamedComponents = project.getTasks().register("pluginNamedComponents", GenerateNamedComponentsTask.class, t -> { + SourceSet mainSourceSet = GradleUtils.getJavaSourceSets(project).findByName(SourceSet.MAIN_SOURCE_SET_NAME); FileCollection dependencyJars = project.getConfigurations().getByName(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME); FileCollection compiledPluginClasses = mainSourceSet.getOutput().getClassesDirs(); FileCollection classPath = dependencyJars.plus(compiledPluginClasses); t.setClasspath(classPath); }); + Configuration pluginScannerConfig = project.getConfigurations().create("pluginScannerConfig"); + DependencyHandler dependencyHandler = project.getDependencies(); + pluginScannerConfig.defaultDependencies( + deps -> deps.add( + dependencyHandler.create("org.elasticsearch:elasticsearch-plugin-scanner:" + VersionProperties.getElasticsearch()) + ) + ); + pluginNamedComponents.configure(t -> { t.setPluginScannerClasspath(pluginScannerConfig); }); final var pluginExtension = project.getExtensions().getByType(PluginPropertiesExtension.class); pluginExtension.getBundleSpec().from(pluginNamedComponents); diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassReaders.java b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassReaders.java deleted file mode 100644 index 40cc66c0a485..000000000000 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassReaders.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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.gradle.plugin.scanner; - -import org.objectweb.asm.ClassReader; - -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.jar.JarFile; -import java.util.stream.Stream; -import java.util.zip.ZipFile; - -/** - * A utility class containing methods to create streams of ASM's ClassReader - * - * @see ClassReader - */ -public class ClassReaders { - private static final String MODULE_INFO = "module-info.class"; - - /** - * This method must be used within a try-with-resources statement or similar - * control structure. - */ - public static Stream ofDirWithJars(String path) { - if (path == null) { - return Stream.empty(); - } - Path dir = Paths.get(path); - try { - return ofPaths(Files.list(dir)); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - /** - * This method must be used within a try-with-resources statement or similar - * control structure. - */ - public static Stream ofPaths(Stream list) { - return list.filter(Files::exists).flatMap(p -> { - if (p.toString().endsWith(".jar")) { - return classesInJar(p); - } else { - return classesInPath(p); - } - }); - } - - private static Stream classesInJar(Path jar) { - try { - JarFile jf = new JarFile(jar.toFile(), true, ZipFile.OPEN_READ, Runtime.version()); - - Stream classReaderStream = jf.versionedStream() - .filter(e -> e.getName().endsWith(".class") && e.getName().equals(MODULE_INFO) == false) - .map(e -> { - try (InputStream is = jf.getInputStream(e)) { - byte[] classBytes = is.readAllBytes(); - return new ClassReader(classBytes); - } catch (IOException ex) { - throw new UncheckedIOException(ex); - } - }); - return classReaderStream; - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - private static Stream classesInPath(Path root) { - try { - Stream stream = Files.walk(root); - return stream.filter(p -> p.toString().endsWith(".class")) - .filter(p -> p.toString().endsWith("module-info.class") == false) - .map(p -> { - try (InputStream is = Files.newInputStream(p)) { - byte[] classBytes = is.readAllBytes(); - return new ClassReader(classBytes); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } -} diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/NamedComponentScanner.java b/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/NamedComponentScanner.java deleted file mode 100644 index 86843c8f20ee..000000000000 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/NamedComponentScanner.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.gradle.plugin.scanner; - -import org.objectweb.asm.AnnotationVisitor; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.Opcodes; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -public class NamedComponentScanner { - - // returns a Map - extensible interface -> map{ namedName -> className } - public Map> scanForNamedClasses(Collection classReaderStream) { - // TODO I don't have access to stable-plugin-api here so I have to hardcode class descriptors - ClassScanner extensibleClassScanner = new ClassScanner("Lorg/elasticsearch/plugin/api/Extensible;", (classname, map) -> { - map.put(classname, classname); - return null; - }); - extensibleClassScanner.visit(classReaderStream.stream()); - - ClassScanner namedComponentsScanner = new ClassScanner( - "Lorg/elasticsearch/plugin/api/NamedComponent;"/*NamedComponent.class*/, - (classname, map) -> new AnnotationVisitor(Opcodes.ASM9) { - @Override - public void visit(String key, Object value) { - assert key.equals("value"); - assert value instanceof String; - map.put(value.toString(), classname); - } - } - ); - - namedComponentsScanner.visit(classReaderStream.stream()); - - Map> componentInfo = new HashMap<>(); - for (var e : namedComponentsScanner.getFoundClasses().entrySet()) { - String name = e.getKey(); - String classnameWithSlashes = e.getValue(); - String extensibleClassnameWithSlashes = extensibleClassScanner.getFoundClasses().get(classnameWithSlashes); - if (extensibleClassnameWithSlashes == null) { - throw new RuntimeException( - "Named component " + name + "(" + pathToClassName(classnameWithSlashes) + ") does not extend from an extensible class" - ); - } - var named = componentInfo.computeIfAbsent(pathToClassName(extensibleClassnameWithSlashes), k -> new HashMap<>()); - named.put(name, pathToClassName(classnameWithSlashes)); - } - return componentInfo; - } - - private String pathToClassName(String classWithSlashes) { - return classWithSlashes.replace('/', '.'); - } -} diff --git a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitorSpec.groovy b/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitorSpec.groovy deleted file mode 100644 index 732fa1c5e0ac..000000000000 --- a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitorSpec.groovy +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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.gradle.plugin.scanner - -import spock.lang.Specification - -import org.elasticsearch.plugin.api.NamedComponent -import org.elasticsearch.plugin.scanner.test_classes.ExtensibleClass -import org.elasticsearch.plugin.scanner.test_classes.ExtensibleInterface -import org.elasticsearch.plugin.scanner.test_classes.ImplementingExtensible -import org.elasticsearch.plugin.scanner.test_classes.SubClass -import org.elasticsearch.plugin.api.Extensible -import org.objectweb.asm.ClassReader -import org.objectweb.asm.Type - -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths - -class AnnotatedHierarchyVisitorSpec extends Specification { - Set foundClasses - AnnotatedHierarchyVisitor visitor - - def setup() { - foundClasses = new HashSet<>() - visitor = - new AnnotatedHierarchyVisitor( - Type.getDescriptor(Extensible.class), (className) -> { - foundClasses.add(className) - return null - } - ) - } - - def "empty result when no classes annotated"() { - when: - performScan(visitor, NamedComponent.class) - - then: - foundClasses.empty - } - - def "single class found when only one is annotated"() { - when: - performScan(visitor, ExtensibleClass.class) - - then: - foundClasses == [classNameToPath(ExtensibleClass.class)] as Set - } - - def "class extending an extensible is also found"() { - when: - performScan(visitor, ExtensibleClass.class, SubClass.class) - - then: - foundClasses == [classNameToPath(ExtensibleClass.class)] as Set - visitor.getClassHierarchy() == [(classNameToPath(ExtensibleClass.class)) : [classNameToPath(SubClass.class)] as Set] - } - - def "interface extending an extensible is also found"() { - when: - performScan(visitor, ImplementingExtensible.class, ExtensibleInterface.class) - - then: - foundClasses == [classNameToPath(ExtensibleInterface.class)] as Set - visitor.getClassHierarchy() == - [(classNameToPath(ExtensibleInterface.class)) : [classNameToPath(ImplementingExtensible.class)] as Set] - } - - private String classNameToPath(Class clazz) { - return clazz.getCanonicalName().replace(".", "/") - } - - private void performScan(AnnotatedHierarchyVisitor classVisitor, Class... classes) throws IOException, URISyntaxException { - for (Class clazz : classes) { - String className = classNameToPath(clazz) + ".class" - def stream = this.getClass().getClassLoader().getResourceAsStream(className) - try (InputStream fileInputStream = stream) { - ClassReader cr = new ClassReader(fileInputStream) - cr.accept(classVisitor, 0) - } - } - } - -} diff --git a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassReadersSpec.groovy b/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassReadersSpec.groovy deleted file mode 100644 index bc448efd2d75..000000000000 --- a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassReadersSpec.groovy +++ /dev/null @@ -1,196 +0,0 @@ -/* - * 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.gradle.plugin.scanner - -import spock.lang.Specification - -import org.elasticsearch.gradle.internal.test.InMemoryJavaCompiler; - -import org.elasticsearch.gradle.internal.test.JarUtils -import org.hamcrest.Matchers -import org.junit.Rule -import org.junit.rules.TemporaryFolder -import org.objectweb.asm.ClassReader - -import java.nio.file.Files -import java.nio.file.Path -import java.util.stream.Collectors -import java.util.stream.Stream - -import static org.hamcrest.MatcherAssert.assertThat - -class ClassReadersSpec extends Specification { - @Rule - TemporaryFolder testProjectDir = new TemporaryFolder() - - private Path tmpDir() throws IOException { - return testProjectDir.root.toPath(); - } - - def "module-info is not returned as a class from jar"() { - when: - final Path tmp = tmpDir(); - final Path dirWithJar = tmp.resolve("jars-dir"); - Files.createDirectories(dirWithJar); - Path jar = dirWithJar.resolve("api.jar"); - JarUtils.createJarWithEntries( - jar, Map.of( - "module-info.class", InMemoryJavaCompiler.compile( - "module-info", """ - module p {} - """) - ) - ) - - - then: - try (Stream classReaderStream = ClassReaders.ofPaths(Stream.of(jar))) { - - assertThat(classReaderStream.collect(Collectors.toList()), Matchers.empty()); - } - } - - - def "two classes are returned in a stream from jar"() { - when: - final Path tmp = tmpDir(); - final Path dirWithJar = tmp.resolve("jars-dir"); - Files.createDirectories(dirWithJar); - Path jar = dirWithJar.resolve("api.jar"); - JarUtils.createJarWithEntries( - jar, Map.of( - "p/A.class", InMemoryJavaCompiler.compile( - "p.A", """ - package p; - public class A {} - """), - "p/B.class", InMemoryJavaCompiler.compile( - "p.B", """ - package p; - public class B {} - """) - ) - ); - - - then: - try (Stream classReaderStream = ClassReaders.ofPaths(Stream.of(jar))) { - List collect = classReaderStream.map(cr -> cr.getClassName()).collect(Collectors.toList()); - assertThat(collect, Matchers.containsInAnyOrder("p/A", "p/B")); - } - } - - - def "on a classpath jars and individual classes are returned"() { - when: - final Path tmp = tmpDir(); - final Path dirWithJar = tmp.resolve("jars-dir"); - Files.createDirectories(dirWithJar); - - Path jar = dirWithJar.resolve("a_b.jar"); - JarUtils.createJarWithEntries( - jar, Map.of( - "p/A.class", InMemoryJavaCompiler.compile( - "p.A", """ - package p; - public class A {} - """), - "p/B.class", InMemoryJavaCompiler.compile( - "p.B", """ - package p; - public class B {} - """) - ) - ); - - Path jar2 = dirWithJar.resolve("c_d.jar"); - JarUtils.createJarWithEntries( - jar2, Map.of( - "p/C.class", InMemoryJavaCompiler.compile( - "p.C", """ - package p; - public class C {} - """), - "p/D.class", InMemoryJavaCompiler.compile( - "p.D", """ - package p; - public class D {} - """) - ) - ); - - InMemoryJavaCompiler.compile( - "p.E", """ - package p; - public class E {} - """ - ); - Files.write( - tmp.resolve("E.class"), InMemoryJavaCompiler.compile( - "p.E", """ - package p; - public class E {} - """) - ); - - - then: - try (Stream classReaderStream = ClassReaders.ofPaths(Stream.of(tmp, jar, jar2))) { - - List collect = classReaderStream.map(cr -> cr.getClassName()).collect(Collectors.toList()); - assertThat(collect, Matchers.containsInAnyOrder("p/A", "p/B", "p/C", "p/D", "p/E")); - } - } - - def "classes from multiple jars in a dir are returned"() { - when: - final Path tmp = tmpDir(); - final Path dirWithJar = tmp.resolve("jars-dir"); - Files.createDirectories(dirWithJar); - - - Path jar = dirWithJar.resolve("a_b.jar"); - JarUtils.createJarWithEntries( - jar, Map.of( - "p/A.class", InMemoryJavaCompiler.compile( - "p.A", """ - package p; - public class A {} - """), - "p/B.class", InMemoryJavaCompiler.compile( - "p.B", """ - package p; - public class B {} - """) - ) - ); - - Path jar2 = dirWithJar.resolve("c_d.jar"); - JarUtils.createJarWithEntries( - jar2, Map.of( - "p/C.class", InMemoryJavaCompiler.compile( - "p.C", """ - package p; - public class C {} - """), - "p/D.class", InMemoryJavaCompiler.compile( - "p.D", """ - package p; - public class D {} - """) - ) - ); - - then: - try (Stream classReaderStream = ClassReaders.ofDirWithJars(dirWithJar.toString())) { - List collect = classReaderStream.map(cr -> cr.getClassName()).collect(Collectors.toList()); - assertThat(collect, Matchers.containsInAnyOrder("p/A", "p/B", "p/C", "p/D")); - } - } -} diff --git a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassScannerSpec.groovy b/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassScannerSpec.groovy deleted file mode 100644 index d03ae28576ae..000000000000 --- a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/ClassScannerSpec.groovy +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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.gradle.plugin.scanner - -import spock.lang.Specification - -import org.elasticsearch.plugin.api.Extensible -import org.hamcrest.Matchers -import org.objectweb.asm.ClassReader -import org.objectweb.asm.Type - -import java.nio.file.Paths -import java.util.stream.Collectors -import java.util.stream.Stream - -import static org.hamcrest.MatcherAssert.assertThat - -class ClassScannerSpec extends Specification { - static final System.Logger logger = System.getLogger(ClassScannerSpec.class.getName()) - def "class and interface hierarchy is scanned"() { - given: - def reader = new ClassScanner( - Type.getDescriptor(Extensible.class), (classname, map) -> { - map.put(classname, classname) - return null - } - ) - Stream classReaderStream = ofClassPath() - logger.log(System.Logger.Level.INFO, "classReaderStream size "+ofClassPath().collect(Collectors.toList()).size()) - - when: - reader.visit(classReaderStream); - Map extensibleClasses = reader.getFoundClasses() - - then: - assertThat( - extensibleClasses, - Matchers.allOf( - Matchers.hasEntry( - "org/elasticsearch/plugin/scanner/test_classes/ExtensibleClass", - "org/elasticsearch/plugin/scanner/test_classes/ExtensibleClass" - ), - Matchers.hasEntry( - "org/elasticsearch/plugin/scanner/test_classes/ImplementingExtensible", - "org/elasticsearch/plugin/scanner/test_classes/ExtensibleInterface" - ), - Matchers.hasEntry( - "org/elasticsearch/plugin/scanner/test_classes/SubClass", - "org/elasticsearch/plugin/scanner/test_classes/ExtensibleClass" - ) - ) - ); - } - - static Stream ofClassPath() throws IOException { - String classpath = System.getProperty("java.class.path"); - logger.log(System.Logger.Level.INFO, "classpath "+classpath); - return ofClassPath(classpath); - } - - static Stream ofClassPath(String classpath) { - if (classpath != null && classpath.equals("") == false) {// todo when do we set cp to "" ? - def classpathSeparator = System.getProperty("path.separator") - logger.log(System.Logger.Level.INFO, "classpathSeparator "+classpathSeparator); - - String[] pathelements = classpath.split(classpathSeparator); - return ClassReaders.ofPaths(Arrays.stream(pathelements).map(Paths::get)); - } - return Stream.empty(); - } - -} diff --git a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/NamedComponentScannerSpec.groovy b/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/NamedComponentScannerSpec.groovy deleted file mode 100644 index fc48ae6a0ad6..000000000000 --- a/build-tools/src/test/groovy/org/elasticsearch/gradle/plugin/scanner/NamedComponentScannerSpec.groovy +++ /dev/null @@ -1,216 +0,0 @@ -/* - * 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.plugin.scanner - -import net.bytebuddy.ByteBuddy -import net.bytebuddy.dynamic.DynamicType -import spock.lang.Specification - -import org.elasticsearch.gradle.internal.test.InMemoryJavaCompiler -import org.elasticsearch.gradle.internal.test.JarUtils -import org.elasticsearch.gradle.internal.test.StableApiJarMocks -import org.elasticsearch.gradle.plugin.scanner.ClassReaders -import org.elasticsearch.gradle.plugin.scanner.NamedComponentScanner -import org.elasticsearch.plugin.scanner.test_classes.ExtensibleClass -import org.elasticsearch.plugin.scanner.test_classes.ExtensibleInterface -import org.elasticsearch.plugin.scanner.test_classes.TestNamedComponent -import org.elasticsearch.plugin.api.Extensible -import org.elasticsearch.plugin.api.NamedComponent -import org.junit.Rule -import org.junit.rules.TemporaryFolder -import org.objectweb.asm.ClassReader - -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.util.stream.Collectors - -import static org.hamcrest.MatcherAssert.assertThat -import static org.hamcrest.Matchers.equalTo - -class NamedComponentScannerSpec extends Specification { - @Rule - TemporaryFolder testProjectDir = new TemporaryFolder() - - private Path tmpDir() throws IOException { - return testProjectDir.root.toPath(); - } - - NamedComponentScanner namedComponentScanner = new NamedComponentScanner(); - - def "named component is found when single class provided"() { - when: - Map> namedComponents = namedComponentScanner.scanForNamedClasses( - classReaderStream(TestNamedComponent.class, ExtensibleInterface.class) - ) - - then: - assertThat( - namedComponents, - equalTo( - Map.of( - ExtensibleInterface.class.getCanonicalName(), - Map.of("test_named_component", TestNamedComponent.class.getCanonicalName()) - ) - ) - ) - - } - - def "named components are found when single jar provided"() { - given: - final Path tmp = tmpDir(); - final Path dirWithJar = tmp.resolve("jars-dir"); - Files.createDirectories(dirWithJar); - Path jar = dirWithJar.resolve("plugin.jar"); - JarUtils.createJarWithEntries( - jar, Map.of( - "p/A.class", InMemoryJavaCompiler.compile( - "p.A", """ - package p; - import org.elasticsearch.plugin.api.*; - import org.elasticsearch.plugin.scanner.test_classes.*; - @NamedComponent("a_component") - public class A extends ExtensibleClass {} - """ - ), "p/B.class", InMemoryJavaCompiler.compile( - "p.B", """ - package p; - import org.elasticsearch.plugin.api.*; - import org.elasticsearch.plugin.scanner.test_classes.*; - @NamedComponent("b_component") - public class B implements ExtensibleInterface{} - """ - ) - ) - ); - StableApiJarMocks.createPluginApiJar(dirWithJar); - StableApiJarMocks.createExtensibleApiJar(dirWithJar);//for instance analysis api - - - Collection classReaderStream = ClassReaders.ofDirWithJars(dirWithJar.toString()).collect(Collectors.toList()) - - when: - Map> namedComponents = namedComponentScanner.scanForNamedClasses(classReaderStream); - - then: - assertThat( - namedComponents, - equalTo( - Map.of( - ExtensibleClass.class.getCanonicalName(), - Map.of("a_component", "p.A"), - ExtensibleInterface.class.getCanonicalName(), - Map.of("b_component", "p.B") - ) - ) - ); - } - - def "named components can extend common super class"() { - given: - Map sources = Map.of( - "p.CustomExtensibleInterface", - """ - package p; - import org.elasticsearch.plugin.api.*; - import org.elasticsearch.plugin.scanner.test_classes.*; - public interface CustomExtensibleInterface extends ExtensibleInterface {} - """, - // note that this class implements a custom interface - "p.CustomExtensibleClass", - """ - package p; - import org.elasticsearch.plugin.api.*; - import org.elasticsearch.plugin.scanner.test_classes.*; - public class CustomExtensibleClass implements CustomExtensibleInterface {} - """, - "p.A", - """ - package p; - import org.elasticsearch.plugin.api.*; - import org.elasticsearch.plugin.scanner.test_classes.*; - @NamedComponent("a_component") - public class A extends CustomExtensibleClass {} - """, - "p.B", - """ - package p; - import org.elasticsearch.plugin.api.*; - import org.elasticsearch.plugin.scanner.test_classes.*; - @NamedComponent("b_component") - public class B implements CustomExtensibleInterface{} - """ - ); - var classToBytes = InMemoryJavaCompiler.compile(sources); - - Map jarEntries = new HashMap<>(); - jarEntries.put("p/CustomExtensibleInterface.class", classToBytes.get("p.CustomExtensibleInterface")); - jarEntries.put("p/CustomExtensibleClass.class", classToBytes.get("p.CustomExtensibleClass")); - jarEntries.put("p/A.class", classToBytes.get("p.A")); - jarEntries.put("p/B.class", classToBytes.get("p.B")); - - final Path tmp = tmpDir(); - final Path dirWithJar = tmp.resolve("jars-dir"); - Files.createDirectories(dirWithJar); - Path jar = dirWithJar.resolve("plugin.jar"); - JarUtils.createJarWithEntries(jar, jarEntries); - - StableApiJarMocks.createPluginApiJar(dirWithJar) - StableApiJarMocks.createExtensibleApiJar(dirWithJar);//for instance analysis api - - Collection classReaderStream = ClassReaders.ofDirWithJars(dirWithJar.toString()).collect(Collectors.toList()) - - when: - Map> namedComponents = namedComponentScanner.scanForNamedClasses(classReaderStream); - - then: - assertThat( - namedComponents, - equalTo( - Map.of( - ExtensibleInterface.class.getCanonicalName(), - Map.of( - "a_component", "p.A", - "b_component", "p.B" - ) - ) - ) - ); - } - - - - private Collection classReaderStream(Class... classes) { - try { - return Arrays.stream(classes).map( - clazz -> { - String className = classNameToPath(clazz) + ".class"; - def stream = this.getClass().getClassLoader().getResourceAsStream(className) - try (InputStream is = stream) { - byte[] classBytes = is.readAllBytes(); - ClassReader classReader = new ClassReader(classBytes); - return classReader; - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - ).collect(Collectors.toList()) - } catch (Exception e) { - throw new RuntimeException(e); - } - - } - - private String classNameToPath(Class clazz) { - return clazz.getCanonicalName().replace(".", "/"); - } - - -} diff --git a/build-tools/src/testFixtures/groovy/org/elasticsearch/gradle/fixtures/TestClasspathUtils.groovy b/build-tools/src/testFixtures/groovy/org/elasticsearch/gradle/fixtures/TestClasspathUtils.groovy index 94e012c95021..e41ace83b75b 100644 --- a/build-tools/src/testFixtures/groovy/org/elasticsearch/gradle/fixtures/TestClasspathUtils.groovy +++ b/build-tools/src/testFixtures/groovy/org/elasticsearch/gradle/fixtures/TestClasspathUtils.groovy @@ -16,17 +16,40 @@ import net.bytebuddy.dynamic.DynamicType import net.bytebuddy.implementation.ExceptionMethod import net.bytebuddy.implementation.FixedValue import net.bytebuddy.implementation.Implementation +import net.bytebuddy.implementation.MethodDelegation + +import org.elasticsearch.gradle.NamedComponentScannerMock import static org.junit.Assert.fail class TestClasspathUtils { + // we cannot access real NamedComponentScanner in libs:plugin-api-scanner so we create a fake class + static void setupNamedComponentScanner(File projectRoot, String version) { + def value = MethodDelegation.to(NamedComponentScannerMock.class) + + DynamicType.Unloaded dynamicType = new ByteBuddy().subclass(Object.class) + .name("org.elasticsearch.plugin.scanner.NamedComponentScanner") + .defineMethod("main", void.class, Visibility.PUBLIC, Ownership.STATIC) + .withParameters(String[].class) + .intercept(value) + .make() + .include(new ByteBuddy().redefine(NamedComponentScannerMock.class).make()) + + try { + dynamicType.toJar(targetFile(projectRoot, "elasticsearch-plugin-scanner", version)) + } catch (IOException e) { + e.printStackTrace() + fail("Cannot setup jdk jar hell classpath") + } + } + static void setupJarHellJar(File projectRoot) { - generateJdkJarHellCheck(projectRoot, "org.elasticsearch.jdk.JarHell", "current", FixedValue.value(TypeDescription.VOID)) + generateJarWithClass(projectRoot, "org.elasticsearch.jdk.JarHell","elasticsearch-core", "current", FixedValue.value(TypeDescription.VOID)) } static void setupJarHellJar(File projectRoot, String version) { - generateJdkJarHellCheck(projectRoot, "org.elasticsearch.jdk.JarHell", version, FixedValue.value(TypeDescription.VOID)) + generateJarWithClass(projectRoot, "org.elasticsearch.jdk.JarHell", "elasticsearch-core", version, FixedValue.value(TypeDescription.VOID)) } static void setupJarJdkClasspath(File projectRoot) { @@ -39,26 +62,28 @@ class TestClasspathUtils { } private static void generateJdkJarHellCheck(File targetDir, String className, Implementation mainImplementation) { - generateJdkJarHellCheck(targetDir, className, "current", mainImplementation) + generateJarWithClass(targetDir, className, "elasticsearch-core", "current", mainImplementation) } - private static void generateJdkJarHellCheck(File targetDir, String className, String version, Implementation mainImplementation) { + + private static void generateJarWithClass(File targetDir, String className, String artifactName, String version, Implementation mainImplementation) { DynamicType.Unloaded dynamicType = new ByteBuddy().subclass(Object.class) .name(className) .defineMethod("main", void.class, Visibility.PUBLIC, Ownership.STATIC) .withParameters(String[].class) .intercept(mainImplementation) .make() + try { - dynamicType.toJar(targetFile(targetDir, version)) + dynamicType.toJar(targetFile(targetDir, artifactName, version)) } catch (IOException e) { e.printStackTrace() fail("Cannot setup jdk jar hell classpath") } } - private static File targetFile(File projectRoot, String version) { - File targetFile = new File(projectRoot, "elasticsearch-core-${version}.jar") + private static File targetFile(File projectRoot, String artifactName, String version) { + File targetFile = new File(projectRoot, "${artifactName}-${version}.jar") println "targetFile = $targetFile" targetFile.getParentFile().mkdirs() diff --git a/libs/build.gradle b/libs/build.gradle index f2b7e247fabd..85c331149fc5 100644 --- a/libs/build.gradle +++ b/libs/build.gradle @@ -26,6 +26,7 @@ configure(subprojects - project('elasticsearch-log4j')) { && false == isPluginApi(project, depProject) && false == depProject.path.equals(':libs:elasticsearch-x-content') && false == depProject.path.equals(':libs:elasticsearch-core') + && false == depProject.path.equals(':libs:elasticsearch-plugin-api') && depProject.path.startsWith(':libs') && depProject.name.startsWith('elasticsearch-')) { throw new InvalidUserDataException("projects in :libs " diff --git a/libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/package-info.java b/libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/package-info.java index b8ecb3165f32..d991fa304ebc 100644 --- a/libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/package-info.java +++ b/libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/package-info.java @@ -16,6 +16,7 @@ * *
  • Interfaces and annotations used by plugin developers are in `api` package * i.e org.elasticsearch.plugin.analysis.api or org.elasticsearch.plugin.api +*
  • *
  • packages which are not meant to be used by plugin developers should not be subpackages of api * i.e org.elasticsearch.plugin.analysis.internal *
  • diff --git a/libs/plugin-scanner/build.gradle b/libs/plugin-scanner/build.gradle new file mode 100644 index 000000000000..39fba248b276 --- /dev/null +++ b/libs/plugin-scanner/build.gradle @@ -0,0 +1,34 @@ +/* + * 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. + */ +apply plugin: 'elasticsearch.publish' + +tasks.named("jarHell").configure { enabled = false } + + +tasks.named("dependencyLicenses").configure { + mapping from: /asm-.*/, to: 'asm' +} + +dependencies { + api project(':libs:elasticsearch-core') + api project(':libs:elasticsearch-plugin-api') + api project(":libs:elasticsearch-x-content") + + implementation 'org.ow2.asm:asm:9.2' + implementation 'org.ow2.asm:asm-tree:9.2' + + testImplementation "junit:junit:${versions.junit}" + testImplementation(project(":test:framework")) { + exclude group: 'org.elasticsearch', module: 'elasticsearch-plugin-scanner' + } +} +tasks.named('forbiddenApisMain').configure { + // TODO: Need to decide how we want to handle for forbidden signatures with the changes to core + replaceSignatureFiles 'jdk-signatures' +} + diff --git a/libs/plugin-scanner/licenses/asm-LICENSE.txt b/libs/plugin-scanner/licenses/asm-LICENSE.txt new file mode 100644 index 000000000000..afb064f2f266 --- /dev/null +++ b/libs/plugin-scanner/licenses/asm-LICENSE.txt @@ -0,0 +1,26 @@ +Copyright (c) 2012 France Télécom +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +THE POSSIBILITY OF SUCH DAMAGE. diff --git a/libs/plugin-scanner/licenses/asm-NOTICE.txt b/libs/plugin-scanner/licenses/asm-NOTICE.txt new file mode 100644 index 000000000000..8d1c8b69c3fc --- /dev/null +++ b/libs/plugin-scanner/licenses/asm-NOTICE.txt @@ -0,0 +1 @@ + diff --git a/libs/plugin-scanner/src/main/java/module-info.java b/libs/plugin-scanner/src/main/java/module-info.java new file mode 100644 index 000000000000..ec1fb6e10b22 --- /dev/null +++ b/libs/plugin-scanner/src/main/java/module-info.java @@ -0,0 +1,16 @@ +/* + * 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. + */ + +module org.elasticsearch.plugin.scanner { + requires org.elasticsearch.base; + requires org.objectweb.asm; + requires org.elasticsearch.plugin.api; + requires org.elasticsearch.xcontent; + + exports org.elasticsearch.plugin.scanner; +} diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitor.java b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitor.java similarity index 98% rename from build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitor.java rename to libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitor.java index 77cb6c97ddb6..a372ff10683a 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/AnnotatedHierarchyVisitor.java +++ b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitor.java @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -package org.elasticsearch.gradle.plugin.scanner; +package org.elasticsearch.plugin.scanner; import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.ClassVisitor; diff --git a/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassReaders.java b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassReaders.java new file mode 100644 index 000000000000..8b1e24f1ba86 --- /dev/null +++ b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassReaders.java @@ -0,0 +1,124 @@ +/* + * 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.plugin.scanner; + +import org.elasticsearch.core.PathUtils; +import org.objectweb.asm.ClassReader; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A utility class containing methods to create streams of ASM's ClassReader + * + * @see ClassReader + */ +public class ClassReaders { + private static final String MODULE_INFO = "module-info.class"; + + /** + * This method must be used within a try-with-resources statement or similar + * control structure. + */ + public static List ofDirWithJars(String path) { + if (path == null) { + return Collections.emptyList(); + } + Path dir = Paths.get(path); + try { + return ofPaths(Files.list(dir)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static List ofPaths(Set classpathFiles) { + return ofPaths(classpathFiles.stream().map(ClassReaders::toPath)); + } + + private static Path toPath(URL url) { + try { + return PathUtils.get(url.toURI()); + } catch (URISyntaxException e) { + throw new AssertionError(e); + } + } + + /** + * This method must be used within a try-with-resources statement or similar + * control structure. + */ + public static List ofPaths(Stream list) { + return list.filter(Files::exists).flatMap(p -> { + if (p.toString().endsWith(".jar")) { + return classesInJar(p).stream(); + } else { + return classesInPath(p).stream(); + } + }).toList(); + } + + private static List classesInJar(Path jar) { + try (FileSystem jarFs = FileSystems.newFileSystem(jar)) { + Path root = jarFs.getPath("/"); + return classesInPath(root); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static List classesInPath(Path root) { + try { + return Files.walk(root) + .filter(p -> p.toString().endsWith(".class")) + .filter(p -> p.toString().endsWith(MODULE_INFO) == false) + .filter(p -> p.toString().startsWith("/META-INF") == false)// skip multi-release files + .map(p -> { + try (InputStream is = Files.newInputStream(p)) { + byte[] classBytes = is.readAllBytes(); + return new ClassReader(classBytes); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static List ofClassPath() throws IOException { + String classpath = System.getProperty("java.class.path"); + return ofClassPath(classpath); + } + + public static List ofClassPath(String classpath) { + if (classpath != null && classpath.equals("") == false) {// todo when do we set cp to "" ? + var classpathSeparator = System.getProperty("path.separator"); + + String[] pathelements = classpath.split(classpathSeparator); + return ofPaths(Arrays.stream(pathelements).map(Paths::get)); + } + return Collections.emptyList(); + } +} diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassScanner.java b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassScanner.java similarity index 87% rename from build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassScanner.java rename to libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassScanner.java index e63ee65e4a1c..4341ac729cba 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/plugin/scanner/ClassScanner.java +++ b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/ClassScanner.java @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -package org.elasticsearch.gradle.plugin.scanner; +package org.elasticsearch.plugin.scanner; import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.ClassReader; @@ -15,10 +15,10 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.BiFunction; -import java.util.stream.Stream; public class ClassScanner { private final Map foundClasses; @@ -32,10 +32,8 @@ public class ClassScanner { ); } - public void visit(Stream classReaderStream) { - try (classReaderStream) { - classReaderStream.forEach(classReader -> classReader.accept(annotatedHierarchyVisitor, ClassReader.SKIP_CODE)); - } + public void visit(List classReaders) { + classReaders.forEach(classReader -> classReader.accept(annotatedHierarchyVisitor, ClassReader.SKIP_CODE)); addExtensibleDescendants(annotatedHierarchyVisitor.getClassHierarchy()); } diff --git a/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/NamedComponentScanner.java b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/NamedComponentScanner.java new file mode 100644 index 000000000000..abc7c777c997 --- /dev/null +++ b/libs/plugin-scanner/src/main/java/org/elasticsearch/plugin/scanner/NamedComponentScanner.java @@ -0,0 +1,102 @@ +/* + * 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.plugin.scanner; + +import org.elasticsearch.plugin.api.Extensible; +import org.elasticsearch.plugin.api.NamedComponent; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class NamedComponentScanner { + // main method to be used by gradle build plugin + public static void main(String[] args) throws IOException { + List classReaders = ClassReaders.ofClassPath(); + + NamedComponentScanner scanner = new NamedComponentScanner(); + Map> namedComponentsMap = scanner.scanForNamedClasses(classReaders); + Path outputFile = Path.of(args[0]); + scanner.writeToFile(namedComponentsMap, outputFile); + } + + // scope for testing + void writeToFile(Map> namedComponentsMap, Path outputFile) throws IOException { + // String json = OBJECT_MAPPER.writeValueAsString(namedComponentsMap); + Files.createDirectories(outputFile.getParent()); + + try (OutputStream outputStream = Files.newOutputStream(outputFile)) { + try (XContentBuilder namedComponents = XContentFactory.jsonBuilder(outputStream)) { + namedComponents.startObject(); + for (Map.Entry> extensibleToComponents : namedComponentsMap.entrySet()) { + namedComponents.startObject(extensibleToComponents.getKey());// extensible class name + for (Map.Entry components : extensibleToComponents.getValue().entrySet()) { + namedComponents.field(components.getKey(), components.getValue());// component name : component class + } + namedComponents.endObject(); + } + namedComponents.endObject(); + } + } + + } + + // returns a Map - extensible interface -> map{ namedName -> className } + public Map> scanForNamedClasses(List classReaderStream) { + ClassScanner extensibleClassScanner = new ClassScanner(Type.getDescriptor(Extensible.class), (classname, map) -> { + map.put(classname, classname); + return null; + }); + extensibleClassScanner.visit(classReaderStream); + + ClassScanner namedComponentsScanner = new ClassScanner( + Type.getDescriptor(NamedComponent.class), + (classname, map) -> new AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visit(String key, Object value) { + assert key.equals("value"); + assert value instanceof String; + map.put(value.toString(), classname); + } + } + ); + + namedComponentsScanner.visit(classReaderStream); + + Map> componentInfo = new HashMap<>(); + for (var e : namedComponentsScanner.getFoundClasses().entrySet()) { + String name = e.getKey(); + String classnameWithSlashes = e.getValue(); + String extensibleClassnameWithSlashes = extensibleClassScanner.getFoundClasses().get(classnameWithSlashes); + if (extensibleClassnameWithSlashes == null) { + throw new RuntimeException( + "Named component " + name + "(" + pathToClassName(classnameWithSlashes) + ") does not extend from an extensible class" + ); + } + var named = componentInfo.computeIfAbsent(pathToClassName(extensibleClassnameWithSlashes), k -> new HashMap<>()); + named.put(name, pathToClassName(classnameWithSlashes)); + } + return componentInfo; + } + + private String pathToClassName(String classWithSlashes) { + return classWithSlashes.replace('/', '.'); + } + +} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitorTests.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitorTests.java new file mode 100644 index 000000000000..c41a5d302327 --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/AnnotatedHierarchyVisitorTests.java @@ -0,0 +1,92 @@ +/* + * 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.plugin.scanner; + +import org.elasticsearch.plugin.api.Extensible; +import org.elasticsearch.plugin.api.NamedComponent; +import org.elasticsearch.plugin.scanner.test_model.ExtensibleClass; +import org.elasticsearch.plugin.scanner.test_model.ExtensibleInterface; +import org.elasticsearch.plugin.scanner.test_model.ImplementingExtensible; +import org.elasticsearch.plugin.scanner.test_model.SubClass; +import org.elasticsearch.test.ESTestCase; +import org.junit.Before; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Type; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class AnnotatedHierarchyVisitorTests extends ESTestCase { + Set foundClasses; + AnnotatedHierarchyVisitor visitor; + + @Before + public void init() { + foundClasses = new HashSet<>(); + visitor = new AnnotatedHierarchyVisitor(Type.getDescriptor(Extensible.class), (className) -> { + foundClasses.add(className); + return null; + }); + } + + public void testNoClassesAnnotated() throws IOException, URISyntaxException { + performScan(visitor, NamedComponent.class); + + assertTrue(foundClasses.isEmpty()); + } + + public void testSingleAnnotatedClass() throws IOException, URISyntaxException { + performScan(visitor, ExtensibleClass.class); + + assertThat(foundClasses, equalTo(Set.of(classNameToPath(ExtensibleClass.class)))); + } + + public void testSubClassofExtensible() throws IOException, URISyntaxException { + performScan(visitor, ExtensibleClass.class, SubClass.class); + + assertThat(foundClasses, equalTo(Set.of(classNameToPath(ExtensibleClass.class)))); + assertThat( + visitor.getClassHierarchy(), + equalTo(Map.of(classNameToPath(ExtensibleClass.class), Set.of(classNameToPath(SubClass.class)))) + ); + } + + public void testSubInterfaceOfExtensible() throws IOException, URISyntaxException { + performScan(visitor, ImplementingExtensible.class, ExtensibleInterface.class); + + assertThat(foundClasses, equalTo(Set.of(classNameToPath(ExtensibleInterface.class)))); + assertThat( + visitor.getClassHierarchy(), + equalTo(Map.of(classNameToPath(ExtensibleInterface.class), Set.of(classNameToPath(ImplementingExtensible.class)))) + ); + } + + private String classNameToPath(Class clazz) { + return clazz.getCanonicalName().replace(".", "/"); + } + + private void performScan(AnnotatedHierarchyVisitor classVisitor, Class... classes) throws IOException, URISyntaxException { + for (Class clazz : classes) { + String className = classNameToPath(clazz) + ".class"; + var stream = this.getClass().getClassLoader().getResourceAsStream(className); + try (InputStream fileInputStream = stream) { + ClassReader cr = new ClassReader(fileInputStream); + cr.accept(classVisitor, 0); + } + } + } + +} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassReadersTests.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassReadersTests.java new file mode 100644 index 000000000000..b05ac4492d9c --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassReadersTests.java @@ -0,0 +1,126 @@ +/* + * 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.plugin.scanner; + +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.compiler.InMemoryJavaCompiler; +import org.elasticsearch.test.jar.JarUtils; +import org.hamcrest.Matchers; +import org.objectweb.asm.ClassReader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ClassReadersTests extends ESTestCase { + + private Path tmpDir() throws IOException { + return createTempDir(); + } + + public void testModuleInfoIsNotReturnedAsAClassFromJar() throws IOException { + final Path tmp = tmpDir(); + final Path dirWithJar = tmp.resolve("jars-dir"); + Files.createDirectories(dirWithJar); + Path jar = dirWithJar.resolve("api.jar"); + JarUtils.createJarWithEntries(jar, Map.of("module-info.class", InMemoryJavaCompiler.compile("module-info", """ + module p {} + """))); + + List classReaders = ClassReaders.ofPaths(Stream.of(jar)); + org.hamcrest.MatcherAssert.assertThat(classReaders, Matchers.empty()); + } + + public void testTwoClassesInAStreamFromJar() throws IOException { + final Path tmp = tmpDir(); + final Path dirWithJar = tmp.resolve("jars-dir"); + Files.createDirectories(dirWithJar); + Path jar = dirWithJar.resolve("api.jar"); + JarUtils.createJarWithEntries(jar, Map.of("p/A.class", InMemoryJavaCompiler.compile("p.A", """ + package p; + public class A {} + """), "p/B.class", InMemoryJavaCompiler.compile("p.B", """ + package p; + public class B {} + """))); + + List classReaders = ClassReaders.ofPaths(Stream.of(jar)); + List collect = classReaders.stream().map(cr -> cr.getClassName()).collect(Collectors.toList()); + org.hamcrest.MatcherAssert.assertThat(collect, Matchers.containsInAnyOrder("p/A", "p/B")); + } + + public void testStreamOfJarsAndIndividualClasses() throws IOException { + final Path tmp = tmpDir(); + final Path dirWithJar = tmp.resolve("jars-dir"); + Files.createDirectories(dirWithJar); + + Path jar = dirWithJar.resolve("a_b.jar"); + JarUtils.createJarWithEntries(jar, Map.of("p/A.class", InMemoryJavaCompiler.compile("p.A", """ + package p; + public class A {} + """), "p/B.class", InMemoryJavaCompiler.compile("p.B", """ + package p; + public class B {} + """))); + + Path jar2 = dirWithJar.resolve("c_d.jar"); + JarUtils.createJarWithEntries(jar2, Map.of("p/C.class", InMemoryJavaCompiler.compile("p.C", """ + package p; + public class C {} + """), "p/D.class", InMemoryJavaCompiler.compile("p.D", """ + package p; + public class D {} + """))); + + InMemoryJavaCompiler.compile("p.E", """ + package p; + public class E {} + """); + Files.write(tmp.resolve("E.class"), InMemoryJavaCompiler.compile("p.E", """ + package p; + public class E {} + """)); + + List classReaders = ClassReaders.ofPaths(Stream.of(tmp, jar, jar2)); + List collect = classReaders.stream().map(cr -> cr.getClassName()).collect(Collectors.toList()); + org.hamcrest.MatcherAssert.assertThat(collect, Matchers.containsInAnyOrder("p/A", "p/B", "p/C", "p/D", "p/E")); + } + + public void testMultipleJarsInADir() throws IOException { + final Path tmp = tmpDir(); + final Path dirWithJar = tmp.resolve("jars-dir"); + Files.createDirectories(dirWithJar); + + Path jar = dirWithJar.resolve("a_b.jar"); + JarUtils.createJarWithEntries(jar, Map.of("p/A.class", InMemoryJavaCompiler.compile("p.A", """ + package p; + public class A {} + """), "p/B.class", InMemoryJavaCompiler.compile("p.B", """ + package p; + public class B {} + """))); + + Path jar2 = dirWithJar.resolve("c_d.jar"); + JarUtils.createJarWithEntries(jar2, Map.of("p/C.class", InMemoryJavaCompiler.compile("p.C", """ + package p; + public class C {} + """), "p/D.class", InMemoryJavaCompiler.compile("p.D", """ + package p; + public class D {} + """))); + + List classReaders = ClassReaders.ofDirWithJars(dirWithJar.toString()); + List collect = classReaders.stream().map(cr -> cr.getClassName()).collect(Collectors.toList()); + org.hamcrest.MatcherAssert.assertThat(collect, Matchers.containsInAnyOrder("p/A", "p/B", "p/C", "p/D")); + } +} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassScannerTests.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassScannerTests.java new file mode 100644 index 000000000000..c0725a1140f8 --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/ClassScannerTests.java @@ -0,0 +1,54 @@ +/* + * 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.plugin.scanner; + +import org.elasticsearch.plugin.api.Extensible; +import org.elasticsearch.test.ESTestCase; +import org.hamcrest.Matchers; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Type; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public class ClassScannerTests extends ESTestCase { + static final System.Logger logger = System.getLogger(ClassScannerTests.class.getName()); + + public void testClassAndInterfaceHierarchy() throws IOException { + var reader = new ClassScanner(Type.getDescriptor(Extensible.class), (classname, map) -> { + map.put(classname, classname); + return null; + }); + List classReaders = ClassReaders.ofClassPath(); + logger.log(System.Logger.Level.INFO, "classReaderStream size " + classReaders.size()); + + reader.visit(classReaders); + Map extensibleClasses = reader.getFoundClasses(); + + org.hamcrest.MatcherAssert.assertThat( + extensibleClasses, + Matchers.allOf( + Matchers.hasEntry( + "org/elasticsearch/plugin/scanner/test_model/ExtensibleClass", + "org/elasticsearch/plugin/scanner/test_model/ExtensibleClass" + ), + Matchers.hasEntry( + "org/elasticsearch/plugin/scanner/test_model/ImplementingExtensible", + "org/elasticsearch/plugin/scanner/test_model/ExtensibleInterface" + ), + Matchers.hasEntry( + "org/elasticsearch/plugin/scanner/test_model/SubClass", + "org/elasticsearch/plugin/scanner/test_model/ExtensibleClass" + ) + ) + ); + } + +} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/NamedComponentScannerTests.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/NamedComponentScannerTests.java new file mode 100644 index 000000000000..dbe652d0a5ba --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/NamedComponentScannerTests.java @@ -0,0 +1,222 @@ +/* + * 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.plugin.scanner; + +import org.elasticsearch.plugin.scanner.test_model.ExtensibleClass; +import org.elasticsearch.plugin.scanner.test_model.ExtensibleInterface; +import org.elasticsearch.plugin.scanner.test_model.TestNamedComponent; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.compiler.InMemoryJavaCompiler; +import org.elasticsearch.test.jar.JarUtils; +import org.objectweb.asm.ClassReader; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.hamcrest.Matchers.equalTo; + +public class NamedComponentScannerTests extends ESTestCase { + + private Path tmpDir() throws IOException { + return createTempDir(); + } + + NamedComponentScanner namedComponentScanner = new NamedComponentScanner(); + + public void testFindNamedComponentInSingleClass() throws URISyntaxException { + Map> namedComponents = namedComponentScanner.scanForNamedClasses( + classReaderStream(TestNamedComponent.class, ExtensibleInterface.class) + ); + + org.hamcrest.MatcherAssert.assertThat( + namedComponents, + equalTo( + Map.of( + ExtensibleInterface.class.getCanonicalName(), + Map.of("test_named_component", TestNamedComponent.class.getCanonicalName()) + ) + ) + ); + + } + + public void testNamedComponentsAreFoundWhenSingleJarProvided() throws IOException { + final Path tmp = tmpDir(); + final Path dirWithJar = tmp.resolve("jars-dir"); + Files.createDirectories(dirWithJar); + Path jar = dirWithJar.resolve("plugin.jar"); + JarUtils.createJarWithEntries(jar, Map.of("p/A.class", InMemoryJavaCompiler.compile("p.A", """ + package p; + import org.elasticsearch.plugin.api.*; + import org.elasticsearch.plugin.scanner.test_model.*; + @NamedComponent("a_component") + public class A extends ExtensibleClass {} + """), "p/B.class", InMemoryJavaCompiler.compile("p.B", """ + package p; + import org.elasticsearch.plugin.api.*; + import org.elasticsearch.plugin.scanner.test_model.*; + @NamedComponent("b_component") + public class B implements ExtensibleInterface{} + """))); + List classReaderStream = Stream.concat( + ClassReaders.ofDirWithJars(dirWithJar.toString()).stream(), + ClassReaders.ofClassPath().stream() + )// contains plugin-api + .toList(); + + Map> namedComponents = namedComponentScanner.scanForNamedClasses(classReaderStream); + + org.hamcrest.MatcherAssert.assertThat( + namedComponents, + equalTo( + Map.of( + ExtensibleClass.class.getCanonicalName(), + Map.of("a_component", "p.A"), + ExtensibleInterface.class.getCanonicalName(), + Map.of( + "b_component", + "p.B", + // noise from classpath + "test_named_component", + "org.elasticsearch.plugin.scanner.test_model.TestNamedComponent" + ) + ) + ) + ); + } + + public void testNamedComponentsCanExtednCommonSuperClass() throws IOException { + Map sources = Map.of( + "p.CustomExtensibleInterface", + """ + package p; + import org.elasticsearch.plugin.api.*; + import org.elasticsearch.plugin.scanner.test_model.*; + public interface CustomExtensibleInterface extends ExtensibleInterface {} + """, + // note that this class implements a custom interface + "p.CustomExtensibleClass", + """ + package p; + import org.elasticsearch.plugin.api.*; + import org.elasticsearch.plugin.scanner.test_model.*; + public class CustomExtensibleClass implements CustomExtensibleInterface {} + """, + "p.A", + """ + package p; + import org.elasticsearch.plugin.api.*; + import org.elasticsearch.plugin.scanner.test_model.*; + @NamedComponent("a_component") + public class A extends CustomExtensibleClass {} + """, + "p.B", + """ + package p; + import org.elasticsearch.plugin.api.*; + import org.elasticsearch.plugin.scanner.test_model.*; + @NamedComponent("b_component") + public class B implements CustomExtensibleInterface{} + """ + ); + var classToBytes = InMemoryJavaCompiler.compile(sources); + + Map jarEntries = new HashMap<>(); + jarEntries.put("p/CustomExtensibleInterface.class", classToBytes.get("p.CustomExtensibleInterface")); + jarEntries.put("p/CustomExtensibleClass.class", classToBytes.get("p.CustomExtensibleClass")); + jarEntries.put("p/A.class", classToBytes.get("p.A")); + jarEntries.put("p/B.class", classToBytes.get("p.B")); + + final Path tmp = tmpDir(); + final Path dirWithJar = tmp.resolve("jars-dir"); + Files.createDirectories(dirWithJar); + Path jar = dirWithJar.resolve("plugin.jar"); + JarUtils.createJarWithEntries(jar, jarEntries); + + List classReaderStream = Stream.concat( + ClassReaders.ofDirWithJars(dirWithJar.toString()).stream(), + ClassReaders.ofClassPath().stream() + )// contains plugin-api + .toList(); + + Map> namedComponents = namedComponentScanner.scanForNamedClasses(classReaderStream); + + org.hamcrest.MatcherAssert.assertThat( + namedComponents, + equalTo( + Map.of( + ExtensibleInterface.class.getCanonicalName(), + Map.of( + "a_component", + "p.A", + "b_component", + "p.B", + "test_named_component", + "org.elasticsearch.plugin.scanner.test_model.TestNamedComponent"// noise from classpath + ) + ) + ) + ); + } + + public void testWriteToFile() throws IOException { + Map extensibleInterfaceComponents = new LinkedHashMap<>(); + extensibleInterfaceComponents.put("a_component", "p.A"); + extensibleInterfaceComponents.put("b_component", "p.B"); + Map> mapToWrite = new LinkedHashMap<>(); + mapToWrite.put(ExtensibleInterface.class.getCanonicalName(), extensibleInterfaceComponents); + + Path path = tmpDir().resolve("file.json"); + namedComponentScanner.writeToFile(mapToWrite, path); + + String jsonMap = Files.readString(path); + assertThat(jsonMap, equalTo(""" + { + "org.elasticsearch.plugin.scanner.test_model.ExtensibleInterface": { + "a_component": "p.A", + "b_component": "p.B" + } + } + """.replaceAll("[\n\r\s]", ""))); + } + + private List classReaderStream(Class... classes) { + try { + return Arrays.stream(classes).map(clazz -> { + String className = classNameToPath(clazz) + ".class"; + var stream = this.getClass().getClassLoader().getResourceAsStream(className); + try (InputStream is = stream) { + byte[] classBytes = is.readAllBytes(); + ClassReader classReader = new ClassReader(classBytes); + return classReader; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }).collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private String classNameToPath(Class clazz) { + return clazz.getCanonicalName().replace(".", "/"); + } + +} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleClass.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleClass.java new file mode 100644 index 000000000000..6e390303386f --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleClass.java @@ -0,0 +1,14 @@ +/* + * 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.plugin.scanner.test_model; + +import org.elasticsearch.plugin.api.Extensible; + +@Extensible +public class ExtensibleClass {} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleInterface.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleInterface.java new file mode 100644 index 000000000000..9a929ac71bc4 --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ExtensibleInterface.java @@ -0,0 +1,14 @@ +/* + * 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.plugin.scanner.test_model; + +import org.elasticsearch.plugin.api.Extensible; + +@Extensible +public interface ExtensibleInterface {} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ImplementingExtensible.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ImplementingExtensible.java new file mode 100644 index 000000000000..2b82972624f5 --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/ImplementingExtensible.java @@ -0,0 +1,11 @@ +/* + * 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.plugin.scanner.test_model; + +public class ImplementingExtensible implements ExtensibleInterface {} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/SubClass.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/SubClass.java new file mode 100644 index 000000000000..b11709023594 --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/SubClass.java @@ -0,0 +1,11 @@ +/* + * 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.plugin.scanner.test_model; + +public class SubClass extends ExtensibleClass {} diff --git a/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/TestNamedComponent.java b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/TestNamedComponent.java new file mode 100644 index 000000000000..ef25e3cae403 --- /dev/null +++ b/libs/plugin-scanner/src/test/java/org/elasticsearch/plugin/scanner/test_model/TestNamedComponent.java @@ -0,0 +1,14 @@ +/* + * 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.plugin.scanner.test_model; + +@org.elasticsearch.plugin.api.NamedComponent("test_named_component") +public class TestNamedComponent implements ExtensibleInterface { + +}