diff --git a/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfo.java b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfo.java new file mode 100644 index 0000000000000..92b642e635b3d --- /dev/null +++ b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfo.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.bootstrap; + +import java.util.List; + +record TestBuildInfo(String component, List locations) {} diff --git a/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfoLocation.java b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfoLocation.java new file mode 100644 index 0000000000000..a918c4ee6f0db --- /dev/null +++ b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfoLocation.java @@ -0,0 +1,12 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.bootstrap; + +record TestBuildInfoLocation(String representativeClass, String module) {} diff --git a/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfoParser.java b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfoParser.java new file mode 100644 index 0000000000000..bd4b9182186ec --- /dev/null +++ b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestBuildInfoParser.java @@ -0,0 +1,107 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.bootstrap; + +import org.elasticsearch.core.SuppressForbidden; +import org.elasticsearch.xcontent.ObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +class TestBuildInfoParser { + + private static final String PLUGIN_TEST_BUILD_INFO_RESOURCES = "META-INF/plugin-test-build-info.json"; + private static final String SERVER_TEST_BUILD_INFO_RESOURCE = "META-INF/server-test-build-info.json"; + + private static final ObjectParser PARSER = new ObjectParser<>("test_build_info", Builder::new); + private static final ObjectParser LOCATION_PARSER = new ObjectParser<>("location", Location::new); + static { + LOCATION_PARSER.declareString(Location::representativeClass, new ParseField("representativeClass")); + LOCATION_PARSER.declareString(Location::module, new ParseField("module")); + + PARSER.declareString(Builder::component, new ParseField("component")); + PARSER.declareObjectArray(Builder::locations, LOCATION_PARSER, new ParseField("locations")); + } + + private static class Location { + private String representativeClass; + private String module; + + public void module(final String module) { + this.module = module; + } + + public void representativeClass(final String representativeClass) { + this.representativeClass = representativeClass; + } + } + + private static final class Builder { + private String component; + private List locations; + + public void component(final String component) { + this.component = component; + } + + public void locations(final List locations) { + this.locations = locations; + } + + TestBuildInfo build() { + return new TestBuildInfo( + component, + locations.stream().map(l -> new TestBuildInfoLocation(l.representativeClass, l.module)).toList() + ); + } + } + + static TestBuildInfo fromXContent(final XContentParser parser) throws IOException { + return PARSER.parse(parser, null).build(); + } + + static List parseAllPluginTestBuildInfo() throws IOException { + var xContent = XContentFactory.xContent(XContentType.JSON); + List pluginsTestBuildInfos = new ArrayList<>(); + var resources = TestBuildInfoParser.class.getClassLoader().getResources(PLUGIN_TEST_BUILD_INFO_RESOURCES); + URL resource; + while ((resource = resources.nextElement()) != null) { + try (var stream = getStream(resource); var parser = xContent.createParser(XContentParserConfiguration.EMPTY, stream)) { + pluginsTestBuildInfos.add(fromXContent(parser)); + } + } + return pluginsTestBuildInfos; + } + + static TestBuildInfo parseServerTestBuildInfo() throws IOException { + var xContent = XContentFactory.xContent(XContentType.JSON); + var resource = TestBuildInfoParser.class.getClassLoader().getResource(SERVER_TEST_BUILD_INFO_RESOURCE); + // No test-build-info for server: this might be a non-gradle build. Proceed without TestBuildInfo + if (resource == null) { + return null; + } + try (var stream = getStream(resource); var parser = xContent.createParser(XContentParserConfiguration.EMPTY, stream)) { + return fromXContent(parser); + } + } + + @SuppressForbidden(reason = "URLs from class loader") + private static InputStream getStream(URL resource) throws IOException { + return resource.openStream(); + } +} diff --git a/test/framework/src/main/java/org/elasticsearch/bootstrap/TestScopeResolver.java b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestScopeResolver.java new file mode 100644 index 0000000000000..82fa0d1dee58c --- /dev/null +++ b/test/framework/src/main/java/org/elasticsearch/bootstrap/TestScopeResolver.java @@ -0,0 +1,104 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.bootstrap; + +import org.elasticsearch.core.SuppressForbidden; +import org.elasticsearch.entitlement.runtime.policy.PolicyManager; +import org.elasticsearch.logging.LogManager; +import org.elasticsearch.logging.Logger; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +record TestScopeResolver(Map scopeMap) { + + private static final Logger logger = LogManager.getLogger(TestScopeResolver.class); + + PolicyManager.PolicyScope getScope(Class callerClass) { + var callerCodeSource = callerClass.getProtectionDomain().getCodeSource(); + assert callerCodeSource != null; + + var location = callerCodeSource.getLocation().toString(); + var scope = scopeMap.get(location); + if (scope == null) { + logger.warn("Cannot identify a scope for class [{}], location [{}]", callerClass.getName(), location); + return PolicyManager.PolicyScope.unknown(location); + } + return scope; + } + + static Function, PolicyManager.PolicyScope> createScopeResolver( + TestBuildInfo serverBuildInfo, + List pluginsBuildInfo + ) { + + Map scopeMap = new HashMap<>(); + for (var pluginBuildInfo : pluginsBuildInfo) { + for (var location : pluginBuildInfo.locations()) { + var codeSource = TestScopeResolver.class.getClassLoader().getResource(location.representativeClass()); + if (codeSource == null) { + throw new IllegalArgumentException("Cannot locate class [" + location.representativeClass() + "]"); + } + try { + scopeMap.put( + getCodeSource(codeSource, location.representativeClass()), + PolicyManager.PolicyScope.plugin(pluginBuildInfo.component(), location.module()) + ); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Cannot locate class [" + location.representativeClass() + "]", e); + } + } + } + + for (var location : serverBuildInfo.locations()) { + var classUrl = TestScopeResolver.class.getClassLoader().getResource(location.representativeClass()); + if (classUrl == null) { + throw new IllegalArgumentException("Cannot locate class [" + location.representativeClass() + "]"); + } + try { + scopeMap.put(getCodeSource(classUrl, location.representativeClass()), PolicyManager.PolicyScope.server(location.module())); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Cannot locate class [" + location.representativeClass() + "]", e); + } + } + + var testScopeResolver = new TestScopeResolver(scopeMap); + return testScopeResolver::getScope; + } + + private static String getCodeSource(URL classUrl, String className) throws MalformedURLException { + if (isJarUrl(classUrl)) { + return extractJarFileUrl(classUrl).toString(); + } + var s = classUrl.toString(); + return s.substring(0, s.indexOf(className)); + } + + private static boolean isJarUrl(URL url) { + return "jar".equals(url.getProtocol()); + } + + @SuppressWarnings("deprecation") + @SuppressForbidden(reason = "need file spec in string form to extract the inner URL form the JAR URL") + private static URL extractJarFileUrl(URL jarUrl) throws MalformedURLException { + String spec = jarUrl.getFile(); + int separator = spec.indexOf("!/"); + + if (separator == -1) { + throw new MalformedURLException(); + } + + return new URL(spec.substring(0, separator)); + } +} diff --git a/test/framework/src/test/java/org/elasticsearch/bootstrap/TestBuildInfoParserTests.java b/test/framework/src/test/java/org/elasticsearch/bootstrap/TestBuildInfoParserTests.java new file mode 100644 index 0000000000000..d32aad5cfdcba --- /dev/null +++ b/test/framework/src/test/java/org/elasticsearch/bootstrap/TestBuildInfoParserTests.java @@ -0,0 +1,75 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.bootstrap; + +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; + +import java.io.IOException; + +import static org.elasticsearch.test.LambdaMatchers.transformedItemsMatch; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; + +public class TestBuildInfoParserTests extends ESTestCase { + public void testSimpleParsing() throws IOException { + + var input = """ + { + "component": "lang-painless", + "locations": [ + { + "representativeClass": "Location.class", + "module": "org.elasticsearch.painless" + }, + { + "representativeClass": "org/objectweb/asm/AnnotationVisitor.class", + "module": "org.objectweb.asm" + }, + { + "representativeClass": "org/antlr/v4/runtime/ANTLRErrorListener.class", + "module": "org.antlr.antlr4.runtime" + }, + { + "representativeClass": "org/objectweb/asm/commons/AdviceAdapter.class", + "module": "org.objectweb.asm.commons" + } + ] + } + """; + + try (var parser = XContentFactory.xContent(XContentType.JSON).createParser(XContentParserConfiguration.EMPTY, input)) { + var testInfo = TestBuildInfoParser.fromXContent(parser); + assertThat(testInfo.component(), is("lang-painless")); + assertThat( + testInfo.locations(), + transformedItemsMatch( + TestBuildInfoLocation::module, + contains("org.elasticsearch.painless", "org.objectweb.asm", "org.antlr.antlr4.runtime", "org.objectweb.asm.commons") + ) + ); + + assertThat( + testInfo.locations(), + transformedItemsMatch( + TestBuildInfoLocation::representativeClass, + contains( + "Location.class", + "org/objectweb/asm/AnnotationVisitor.class", + "org/antlr/v4/runtime/ANTLRErrorListener.class", + "org/objectweb/asm/commons/AdviceAdapter.class" + ) + ) + ); + } + } +} diff --git a/test/framework/src/test/java/org/elasticsearch/bootstrap/TestScopeResolverTests.java b/test/framework/src/test/java/org/elasticsearch/bootstrap/TestScopeResolverTests.java new file mode 100644 index 0000000000000..24d8f26342797 --- /dev/null +++ b/test/framework/src/test/java/org/elasticsearch/bootstrap/TestScopeResolverTests.java @@ -0,0 +1,48 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.bootstrap; + +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.test.ESTestCase; + +import java.util.List; + +import static org.hamcrest.Matchers.is; + +public class TestScopeResolverTests extends ESTestCase { + + public void testScopeResolverServerClass() { + var testBuildInfo = new TestBuildInfo( + "server", + List.of(new TestBuildInfoLocation("org/elasticsearch/Build.class", "org.elasticsearch.server")) + ); + var resolver = TestScopeResolver.createScopeResolver(testBuildInfo, List.of()); + + var scope = resolver.apply(Plugin.class); + assertThat(scope.componentName(), is("(server)")); + assertThat(scope.moduleName(), is("org.elasticsearch.server")); + } + + public void testScopeResolverInternalClass() { + var testBuildInfo = new TestBuildInfo( + "server", + List.of(new TestBuildInfoLocation("org/elasticsearch/Build.class", "org.elasticsearch.server")) + ); + var testOwnBuildInfo = new TestBuildInfo( + "test-component", + List.of(new TestBuildInfoLocation("org/elasticsearch/bootstrap/TestBuildInfoParserTests.class", "test-module-name")) + ); + var resolver = TestScopeResolver.createScopeResolver(testBuildInfo, List.of(testOwnBuildInfo)); + + var scope = resolver.apply(this.getClass()); + assertThat(scope.componentName(), is("test-component")); + assertThat(scope.moduleName(), is("test-module-name")); + } +}