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 { + +}