diff --git a/paimon-filesystems/paimon-jindo/pom.xml b/paimon-filesystems/paimon-jindo/pom.xml index e96a5ff63bd6..f70fe56d9185 100644 --- a/paimon-filesystems/paimon-jindo/pom.xml +++ b/paimon-filesystems/paimon-jindo/pom.xml @@ -34,6 +34,7 @@ 3.17.4 6.9.1 + paimon-plugin-jindo-oss @@ -55,6 +56,12 @@ org.apache.paimon paimon-oss-impl ${project.version} + + + * + * + + @@ -160,6 +167,9 @@ org.apache.maven.plugins maven-jar-plugin + + org/apache/paimon/jindo/JindoBlobPresigner*.class + true @@ -168,34 +178,121 @@ + + org.apache.maven.plugins + maven-resources-plugin + + + copy-oss-presigner-implementation + prepare-package + + copy-resources + + + ${project.build.outputDirectory}/${oss.plugin.directory} + + + ${project.build.outputDirectory} + + org/apache/paimon/jindo/JindoBlobPresigner*.class + + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + unpack-oss-runtime + prepare-package + + unpack-dependencies + + + runtime + paimon-oss-impl,slf4j-api,jsr305 + ${project.build.outputDirectory}/${oss.plugin.directory} + META-INF/*.SF,META-INF/*.DSA,META-INF/*.RSA,META-INF/maven/** + + + + unpack-oss-presigner + prepare-package + + unpack + + + + + org.apache.paimon + paimon-oss-impl + ${project.version} + true + ${project.build.outputDirectory}/${oss.plugin.directory} + org/apache/paimon/oss/OSSBlobPresigner*.class + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + integration-tests + + + ${project.build.directory}/${project.build.finalName}.jar + + + + + + org.apache.maven.plugins maven-shade-plugin - org.apache.paimon:paimon-oss-impl - com.aliyun.oss:aliyun-sdk-oss - com.aliyun:* - com.google.code.gson:gson - commons-codec:commons-codec - commons-logging:commons-logging - io.opentracing:* - org.apache.httpcomponents:* - org.codehaus.jettison:jettison - org.ini4j:ini4j - org.jdom:jdom2 - stax:stax-api + *:* + + org.slf4j:slf4j-api + com.google.code.findbugs:jsr305 + - org.apache.paimon:paimon-oss-impl + *:* - org/apache/paimon/oss/OSSBlobPresigner.class - META-INF/versions/11/** + org/apache/paimon/jindo/** + ${oss.plugin.directory}/** + META-INF/MANIFEST.MF + META-INF/LICENSE* + META-INF/licenses/LICENSE.jdom + META-INF/licenses/LICENSE.jaxb + META-INF/NOTICE* + META-INF/DEPENDENCIES + META-INF/maven/** + META-INF/services/org.apache.paimon.fs.FileIOLoader + META-INF/versions/11/javax/xml/bind/** + + org.apache.paimon:paimon-oss-impl + + META-INF/NOTICE* + + diff --git a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java new file mode 100644 index 000000000000..57e399e9d55f --- /dev/null +++ b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.jindo; + +import org.apache.paimon.data.BlobDescriptor; +import org.apache.paimon.fs.Path; +import org.apache.paimon.options.Options; +import org.apache.paimon.oss.OSSBlobPresigner; +import org.apache.paimon.utils.StringUtils; + +import com.aliyun.oss.OSSClient; +import com.aliyun.oss.OSSClientBuilder; + +import java.io.IOException; +import java.time.Duration; + +/** OSS presigning implementation loaded from the private plugin directory. */ +public class JindoBlobPresigner implements JindoFileIO.BlobPresigner { + + private OSSClient client; + + public JindoBlobPresigner() {} + + JindoBlobPresigner(OSSClient client) { + this.client = client; + } + + @Override + public void configure(Options options) { + client = createBlobClient(options); + } + + @Override + public String create(Path tableRoot, BlobDescriptor descriptor, Duration validity) + throws IOException { + return OSSBlobPresigner.create(client, tableRoot, descriptor, validity); + } + + @Override + public void close() { + client.shutdown(); + } + + static OSSClient createBlobClient(Options options) { + String endpoint = options.get("fs.oss.endpoint"); + if (!endpoint.contains("://")) { + endpoint = "https://" + endpoint; + } + String securityToken = options.get("fs.oss.securityToken"); + OSSClientBuilder builder = new OSSClientBuilder(); + OSSClient client = + (OSSClient) + (StringUtils.isNullOrWhitespaceOnly(securityToken) + ? builder.build( + endpoint, + options.get("fs.oss.accessKeyId"), + options.get("fs.oss.accessKeySecret")) + : builder.build( + endpoint, + options.get("fs.oss.accessKeyId"), + options.get("fs.oss.accessKeySecret"), + securityToken)); + String region = options.get("fs.oss.region"); + if (!StringUtils.isNullOrWhitespaceOnly(region)) { + client.setRegion(region); + } + return client; + } +} diff --git a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java index 1e4646d4b9f9..31e0d440d969 100644 --- a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java +++ b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java @@ -25,7 +25,7 @@ import org.apache.paimon.fs.Path; import org.apache.paimon.fs.TwoPhaseOutputStream; import org.apache.paimon.options.Options; -import org.apache.paimon.oss.OSSBlobPresigner; +import org.apache.paimon.plugin.PluginLoader; import org.apache.paimon.utils.IOUtils; import org.apache.paimon.utils.Pair; import org.apache.paimon.utils.SensitiveConfigUtils; @@ -36,8 +36,6 @@ import com.aliyun.jindodata.oss.JindoOssFileSystem; import com.aliyun.jindodata.oss.auth.SimpleCredentialsProvider; import com.aliyun.jindodata.store.JindoMpuStore; -import com.aliyun.oss.OSSClient; -import com.aliyun.oss.OSSClientBuilder; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.slf4j.Logger; @@ -70,11 +68,9 @@ public class JindoFileIO extends HadoopCompliantFileIO implements HadoopOptionsP */ private static final String[] CONFIG_PREFIXES = {"fs."}; - private static final String OSS_ENDPOINT = "fs.oss.endpoint"; private static final String OSS_ACCESS_KEY_ID = "fs.oss.accessKeyId"; private static final String OSS_ACCESS_KEY_SECRET = "fs.oss.accessKeySecret"; private static final String OSS_SECURITY_TOKEN = "fs.oss.securityToken"; - private static final String OSS_REGION = "fs.oss.region"; private static final String OSS_USER_AGENT_EXTENDED = "fs.oss.user.agent.extended"; private static final String OSS_SHOW_DIR_TIMESTAMP = "fs.oss.show-dir-timestamp"; private static final String DLF_ACCESS_TRACKING_EXTENDED_INFO = @@ -99,12 +95,12 @@ public class JindoFileIO extends HadoopCompliantFileIO implements HadoopOptionsP private Options hadoopOptions; private Options hadoopOptionsWithCache; private boolean allowCache = true; - private transient OSSClient blobClient; + private transient BlobPresigner blobPresigner; public JindoFileIO() {} - JindoFileIO(OSSClient blobClient) { - this.blobClient = blobClient; + JindoFileIO(BlobPresigner blobPresigner) { + this.blobPresigner = blobPresigner; } @Override @@ -229,40 +225,45 @@ public TwoPhaseOutputStream newTwoPhaseOutputStream(Path path, boolean overwrite @Override public String createBlobPresignedUrl( Path tableRoot, BlobDescriptor descriptor, Duration validity) throws IOException { - return OSSBlobPresigner.create(blobClient(), tableRoot, descriptor, validity); + BlobPresigner presigner = blobPresigner(); + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + try { + thread.setContextClassLoader(presigner.getClass().getClassLoader()); + return presigner.create(tableRoot, descriptor, validity); + } finally { + thread.setContextClassLoader(previous); + } } - private synchronized OSSClient blobClient() { - if (blobClient == null) { - blobClient = createBlobClient(hadoopOptions); + private synchronized BlobPresigner blobPresigner() { + if (blobPresigner == null) { + PluginLoader loader = BlobPlugin.getLoader(); + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + try { + thread.setContextClassLoader(loader.submoduleClassLoader()); + BlobPresigner presigner = + loader.newInstance("org.apache.paimon.jindo.JindoBlobPresigner"); + presigner.configure(hadoopOptions); + blobPresigner = presigner; + } finally { + thread.setContextClassLoader(previous); + } } - return blobClient; + return blobPresigner; } - static OSSClient createBlobClient(Options options) { - String endpoint = options.get(OSS_ENDPOINT); - if (!endpoint.contains("://")) { - endpoint = "https://" + endpoint; - } - String securityToken = options.get(OSS_SECURITY_TOKEN); - OSSClientBuilder builder = new OSSClientBuilder(); - OSSClient client = - (OSSClient) - (StringUtils.isNullOrWhitespaceOnly(securityToken) - ? builder.build( - endpoint, - options.get(OSS_ACCESS_KEY_ID), - options.get(OSS_ACCESS_KEY_SECRET)) - : builder.build( - endpoint, - options.get(OSS_ACCESS_KEY_ID), - options.get(OSS_ACCESS_KEY_SECRET), - securityToken)); - String region = options.get(OSS_REGION); - if (!StringUtils.isNullOrWhitespaceOnly(region)) { - client.setRegion(region); + private static class BlobPlugin { + + private static PluginLoader loader; + + private static synchronized PluginLoader getLoader() { + if (loader == null) { + loader = new PluginLoader("paimon-plugin-jindo-oss"); + } + return loader; } - return client; } @Override @@ -314,9 +315,16 @@ protected Pair createFileSystem( @Override public synchronized void close() { - if (blobClient != null) { - blobClient.shutdown(); - blobClient = null; + if (blobPresigner != null) { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + try { + thread.setContextClassLoader(blobPresigner.getClass().getClassLoader()); + blobPresigner.close(); + blobPresigner = null; + } finally { + thread.setContextClassLoader(previous); + } } if (!allowCache) { fsMap.values().stream().map(Pair::getKey).forEach(IOUtils::closeQuietly); @@ -324,6 +332,18 @@ public synchronized void close() { } } + /** Contract shared with the isolated OSS implementation. */ + public interface BlobPresigner extends AutoCloseable { + + void configure(Options options); + + String create(Path tableRoot, BlobDescriptor descriptor, Duration validity) + throws IOException; + + @Override + void close(); + } + private static class CacheKey { private final Options options; diff --git a/paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE b/paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE new file mode 100644 index 000000000000..e5d564724e87 --- /dev/null +++ b/paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE @@ -0,0 +1,32 @@ +paimon-jindo +Copyright 2023-2026 The Apache Software Foundation + +This project includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +This project bundles the following dependencies under the Apache Software License 2.0 (http://www.apache.org/licenses/LICENSE-2.0.txt) + +- com.aliyun.oss:aliyun-sdk-oss:3.17.4 +- com.aliyun:aliyun-java-sdk-core:4.5.10 +- com.aliyun:aliyun-java-sdk-kms:2.11.0 +- com.aliyun:aliyun-java-sdk-ram:3.1.0 +- com.google.code.gson:gson:2.8.6 +- commons-codec:commons-codec:1.11 +- commons-logging:commons-logging:1.1.3 +- io.opentracing:opentracing-api:0.33.0 +- io.opentracing:opentracing-noop:0.33.0 +- io.opentracing:opentracing-util:0.33.0 +- org.apache.httpcomponents:httpclient:4.5.13 +- org.apache.httpcomponents:httpcore:4.4.13 +- org.codehaus.jettison:jettison:1.5.4 +- org.ini4j:ini4j:0.5.4 + +This project bundles the following dependencies under the JDOM license. +You find it under licenses/LICENSE.jdom. + +- org.jdom:jdom2:2.0.6.1 + +This project bundles the following dependencies under the CDDL 1.1 license. +You find it under licenses/LICENSE.jaxb. + +- javax.xml.bind:jaxb-api:2.3.1 diff --git a/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java index 91406badb4eb..06b430a2a23f 100644 --- a/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java +++ b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java @@ -37,6 +37,7 @@ import java.net.URL; import java.security.MessageDigest; import java.time.Duration; +import java.util.Date; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -85,7 +86,7 @@ public void testCreateBlobClientUsesConfiguredSts() { options.set("fs.oss.accessKeySecret", "access-secret"); options.set("fs.oss.securityToken", "security-token"); - OSSClient client = JindoFileIO.createBlobClient(options); + OSSClient client = JindoBlobPresigner.createBlobClient(options); try { assertThat(client.getEndpoint()).isEqualTo(URI.create("https://oss.example.com")); assertThat(client.getObjectOperation().getRegion()).isEqualTo("cn-hangzhou"); @@ -95,6 +96,17 @@ public void testCreateBlobClientUsesConfiguredSts() { .isEqualTo("access-secret"); assertThat(client.getCredentialsProvider().getCredentials().getSecurityToken()) .isEqualTo("security-token"); + URL signedUrl = + client.generatePresignedUrl( + "bucket", "object", new Date(System.currentTimeMillis() + 60_000)); + assertThat(signedUrl.getProtocol()).isEqualTo("https"); + assertThat(signedUrl.getHost()).isEqualTo("bucket.oss.example.com"); + assertThat(signedUrl.getPath()).isEqualTo("/object"); + assertThat(signedUrl.getQuery()) + .contains( + "OSSAccessKeyId=access-key", + "Signature=", + "security-token=security-token"); } finally { client.shutdown(); } @@ -119,7 +131,7 @@ public void testCreateBlobPresignedUrlUsesOssClient() throws Exception { "https://bucket.oss.example.com/" + invocation.getArgument(1))); - JindoFileIO fileIO = new JindoFileIO(client); + JindoFileIO fileIO = new JindoFileIO(new JindoBlobPresigner(client)); assertThat(fileIO.createBlobPresignedUrl(tableRoot, descriptor, Duration.ofHours(1))) .startsWith("https://bucket.oss.example.com/table/data/_bloburl_"); diff --git a/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java new file mode 100644 index 000000000000..7f4e2451aef1 --- /dev/null +++ b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java @@ -0,0 +1,522 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.jindo; + +import org.apache.paimon.catalog.CatalogContext; +import org.apache.paimon.data.BlobDescriptor; +import org.apache.paimon.fs.Path; +import org.apache.paimon.options.Options; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Packaged-artifact tests for {@link JindoFileIO}. */ +public class JindoPluginITCase { + + private static final String PLUGIN_DIRECTORY = "paimon-plugin-jindo-oss/"; + + private static final String PRESIGNER_IMPLEMENTATION = + "org.apache.paimon.jindo.JindoBlobPresigner"; + + @TempDir java.nio.file.Path tempDir; + + @Test + public void testPluginJarContents() throws Exception { + try (JarFile jar = new JarFile(pluginJar())) { + Set entries = + jar.stream() + .map(JarEntry::getName) + .map(JindoPluginITCase::normalizeEntry) + .collect(Collectors.toSet()); + for (String originalPackage : + Arrays.asList( + "com/google/gson/", + "com/aliyun/oss/", + "com/aliyuncs/", + "org/apache/http/", + "org/apache/commons/codec/", + "org/apache/commons/logging/", + "org/jdom2/", + "org/codehaus/jettison/", + "io/opentracing/", + "org/ini4j/", + "javax/xml/stream/")) { + assertThat(entries) + .as("Private package %s at the root of %s", originalPackage, jar.getName()) + .noneMatch(entry -> entry.startsWith(originalPackage)); + } + for (String representativeClass : + Arrays.asList( + PRESIGNER_IMPLEMENTATION.replace('.', '/'), + "org/apache/paimon/oss/OSSBlobPresigner", + "com/google/gson/Gson", + "com/aliyun/oss/OSSClient", + "com/aliyuncs/DefaultAcsClient", + "org/apache/http/impl/client/HttpClients", + "org/apache/commons/codec/binary/Base64", + "org/apache/commons/logging/LogFactory", + "org/jdom2/Document", + "org/codehaus/jettison/json/JSONObject", + "io/opentracing/Tracer", + "org/ini4j/Ini")) { + assertThat(entries) + .contains(PLUGIN_DIRECTORY + representativeClass + ".class") + .doesNotContain(representativeClass + ".class"); + } + for (String rootEntry : + Arrays.asList( + "org/apache/paimon/jindo/JindoFileIO.class", + "org/apache/paimon/jindo/JindoFileIO$BlobPresigner.class", + "org/apache/paimon/jindo/JindoLoader.class", + "META-INF/services/org.apache.paimon.fs.FileIOLoader")) { + assertThat(entries) + .contains(rootEntry) + .doesNotContain(PLUGIN_DIRECTORY + rootEntry); + } + for (String resource : + Arrays.asList( + "versioninfo.properties", "common.properties", "oss.properties")) { + assertThat(entries).contains(PLUGIN_DIRECTORY + resource).doesNotContain(resource); + } + for (String providedPackage : + Arrays.asList("com/aliyun/jindodata/", "org/apache/hadoop/", "org/slf4j/")) { + assertThat(entries) + .noneMatch( + entry -> + entry.startsWith(providedPackage) + || entry.startsWith( + PLUGIN_DIRECTORY + providedPackage)); + } + assertPrivatePaimonClasses(entries); + } + } + + @Test + public void testPluginWithMinimalHostClasspath() throws Exception { + assertPluginClassLoading(false, false); + } + + @Test + public void testPluginBeforeHostDependencies() throws Exception { + assertPluginClassLoading(true, true); + } + + @Test + public void testPluginAfterHostDependencies() throws Exception { + assertPluginClassLoading(false, true); + } + + @Test + public void testPrivatePaimonClassesAllowNestedImplementations() { + Set entries = + new HashSet<>( + Arrays.asList( + PLUGIN_DIRECTORY + + "org/apache/paimon/jindo/JindoBlobPresigner.class", + PLUGIN_DIRECTORY + "org/apache/paimon/oss/OSSBlobPresigner.class", + PLUGIN_DIRECTORY + + "org/apache/paimon/jindo/JindoBlobPresigner$1.class", + PLUGIN_DIRECTORY + + "META-INF/versions/11/org/apache/paimon/oss/OSSBlobPresigner$Helper.class")); + assertPrivatePaimonClasses(entries); + } + + @Test + public void testPrivatePaimonClassesRejectHostClasses() { + for (String unexpectedClass : + Arrays.asList( + PLUGIN_DIRECTORY + "org/apache/paimon/fs/Path.class", + PLUGIN_DIRECTORY + "META-INF/versions/11/org/apache/paimon/fs/Path.class", + "META-INF/versions/11/" + + PLUGIN_DIRECTORY + + "org/apache/paimon/fs/Path.class", + PLUGIN_DIRECTORY + + "org/apache/paimon/jindo/JindoFileIO$BlobPresigner.class", + PLUGIN_DIRECTORY + "org/apache/paimon/oss/OSSBlobPresignerOther.class")) { + Set entries = + new HashSet<>( + Arrays.asList( + PLUGIN_DIRECTORY + + "org/apache/paimon/jindo/JindoBlobPresigner.class", + PLUGIN_DIRECTORY + + "org/apache/paimon/oss/OSSBlobPresigner.class", + unexpectedClass)); + assertThatThrownBy(() -> assertPrivatePaimonClasses(entries)) + .as("Reject private host class %s", unexpectedClass) + .isInstanceOf(AssertionError.class); + } + } + + private static String normalizeEntry(String entry) { + return entry.replaceFirst("^META-INF/versions/[0-9]+/", "") + .replaceFirst( + "^" + PLUGIN_DIRECTORY + "META-INF/versions/[0-9]+/", PLUGIN_DIRECTORY); + } + + private static void assertPrivatePaimonClasses(Set entries) { + assertThat(entries.stream().map(JindoPluginITCase::normalizeEntry)) + .filteredOn( + entry -> + entry.startsWith(PLUGIN_DIRECTORY + "org/apache/paimon/") + && entry.endsWith(".class")) + .allMatch( + entry -> + entry.matches( + PLUGIN_DIRECTORY + + "org/apache/paimon/(jindo/JindoBlobPresigner|oss/OSSBlobPresigner)(\\$[^/]+)?\\.class")); + } + + private static File pluginJar() throws Exception { + File jar = new File(System.getProperty("jindo.plugin.jar")).getCanonicalFile(); + assertThat(jar).isFile(); + return jar; + } + + private static File classLocation(Class type) throws Exception { + return new File(type.getProtectionDomain().getCodeSource().getLocation().toURI()) + .getCanonicalFile(); + } + + private static Class loadClassFrom( + ClassLoader classLoader, String className, File expectedJar) throws Exception { + Class type = Class.forName(className, true, classLoader); + assertThat(type.getClassLoader()).as("Loader of %s", className).isSameAs(classLoader); + assertThat(classLocation(type)).as("Source of %s", className).isEqualTo(expectedJar); + return type; + } + + private void assertPluginClassLoading(boolean jarFirst, boolean conflictingDependencies) + throws Exception { + File jar = pluginJar(); + List urls = new ArrayList<>(); + urls.add(tempDir.toUri().toURL()); + List hostClasses = + new ArrayList<>( + Arrays.asList( + "org.apache.paimon.plugin.PluginLoader", + "org.apache.paimon.options.Options", + "org.apache.paimon.shade.guava30.com.google.common.collect.Iterators", + "com.aliyun.jindodata.Version", + "com.aliyun.jindodata.common.JindoHadoopSystem")); + for (String artifact : Arrays.asList("hadoop-common", "slf4j-api")) { + urls.add(hostDependency(artifact).toURI().toURL()); + } + if (conflictingDependencies) { + urls.add(hostDependency("commons-logging").toURI().toURL()); + hostClasses.addAll( + Arrays.asList( + "com.google.gson.Gson", + "org.apache.http.client.methods.HttpGet", + "org.apache.http.HttpVersion", + "org.apache.paimon.oss.OSSBlobPresigner", + "com.aliyun.oss.OSSClient")); + } + for (String className : hostClasses) { + URL location = classLocation(testClass(className)).toURI().toURL(); + if (!urls.contains(location)) { + urls.add(location); + } + } + Files.write( + tempDir.resolve("versioninfo.properties"), + "version=host-version\n".getBytes(StandardCharsets.UTF_8)); + Files.write( + tempDir.resolve("common.properties"), + "ConnectionError=host-error\n".getBytes(StandardCharsets.UTF_8)); + urls.add(jarFirst ? 0 : urls.size(), jar.toURI().toURL()); + Thread thread = Thread.currentThread(); + ClassLoader contextClassLoader = thread.getContextClassLoader(); + try (URLClassLoader host = + new URLClassLoader( + urls.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent())) { + thread.setContextClassLoader(host); + assertThatThrownBy(() -> host.loadClass(PRESIGNER_IMPLEMENTATION)) + .isInstanceOf(ClassNotFoundException.class); + assertThat(host.getResource(PRESIGNER_IMPLEMENTATION.replace('.', '/') + ".class")) + .isNull(); + Class optionsClass = host.loadClass(Options.class.getName()); + Map settings = new HashMap<>(); + settings.put("fs.oss.endpoint", "oss.example.com"); + settings.put("fs.oss.region", "cn-hangzhou"); + settings.put("fs.oss.accessKeyId", "access-key"); + settings.put("fs.oss.accessKeySecret", "access-secret"); + settings.put("fs.oss.securityToken", "security-token"); + Object options = optionsClass.getConstructor(Map.class).newInstance(settings); + Class implementation = assertPublicEntryPoint(host, jar, options); + try (URLClassLoader plugin = (URLClassLoader) implementation.getClassLoader()) { + assertThat(implementation.getSimpleName()).isEqualTo("JindoBlobPresigner"); + assertThat(implementation.getEnclosingClass()).isNull(); + assertThat(implementation.getClassLoader()).isSameAs(plugin).isNotSameAs(host); + assertThat(implementation.getInterfaces()) + .contains(host.loadClass(JindoFileIO.BlobPresigner.class.getName())); + Class logFactory = plugin.loadClass("org.apache.commons.logging.LogFactory"); + if (conflictingDependencies) { + assertThat(logFactory) + .isSameAs(host.loadClass("org.apache.commons.logging.LogFactory")); + } else { + assertThat(logFactory.getClassLoader()).isSameAs(plugin); + assertThatThrownBy( + () -> host.loadClass("org.apache.commons.logging.LogFactory")) + .isInstanceOf(ClassNotFoundException.class); + } + for (String className : + Arrays.asList( + "com.google.gson.Gson", + "org.apache.http.client.methods.HttpGet", + "org.apache.paimon.oss.OSSBlobPresigner", + "com.aliyun.oss.OSSClient")) { + Class privateClass = Class.forName(className, true, plugin); + assertThat(privateClass.getClassLoader()).isSameAs(plugin); + assertThat(privateClass.getProtectionDomain().getCodeSource().getLocation()) + .isEqualTo(host.getResource(PLUGIN_DIRECTORY)); + if (conflictingDependencies) { + assertThat( + loadClassFrom( + host, + className, + classLocation(testClass(className)))) + .isNotSameAs(privateClass); + } else { + assertThatThrownBy(() -> host.loadClass(className)) + .isInstanceOf(ClassNotFoundException.class); + assertThat(host.getResource(className.replace('.', '/') + ".class")) + .isNull(); + } + } + assertGson(plugin); + if (conflictingDependencies) { + assertGson(host); + } + assertPluginResources(host, plugin); + assertBlobClient(implementation, optionsClass, options, plugin); + assertThat(thread.getContextClassLoader()).isSameAs(host); + } + Class serviceClass = host.loadClass("org.apache.paimon.fs.FileIOLoader"); + boolean found = false; + for (Object provider : ServiceLoader.load(serviceClass, host)) { + if (provider.getClass().getName().equals("org.apache.paimon.jindo.JindoLoader")) { + loadClassFrom(host, provider.getClass().getName(), jar); + assertThat(serviceClass.getMethod("getScheme").invoke(provider)) + .isEqualTo("oss"); + found = true; + break; + } + } + assertThat(found).as("Jindo FileIOLoader SPI from plugin artifact").isTrue(); + } finally { + thread.setContextClassLoader(contextClassLoader); + } + } + + private static Class testClass(String name) throws ClassNotFoundException { + return Class.forName(name, false, JindoPluginITCase.class.getClassLoader()); + } + + private static File hostDependency(String artifact) throws IOException { + String classPath = + System.getProperty( + "surefire.test.class.path", System.getProperty("java.class.path")); + for (String entry : classPath.split(File.pathSeparator)) { + File dependency = new File(entry); + if (dependency.isFile() + && dependency.getName().startsWith(artifact + "-") + && dependency.getName().endsWith(".jar")) { + return dependency.getCanonicalFile(); + } + } + throw new AssertionError("Missing host dependency: " + artifact); + } + + private static Class assertPublicEntryPoint(ClassLoader host, File jar, Object options) + throws Exception { + Class fileIOClass = loadClassFrom(host, "org.apache.paimon.jindo.JindoFileIO", jar); + assertThat(fileIOClass.getDeclaredClasses()) + .contains(host.loadClass(JindoFileIO.BlobPresigner.class.getName())) + .allSatisfy(nestedClass -> assertThat(nestedClass.getClassLoader()).isSameAs(host)); + Object fileIO = fileIOClass.getConstructor().newInstance(); + Field presignerField = fileIOClass.getDeclaredField("blobPresigner"); + presignerField.setAccessible(true); + try { + assertThat(presignerField.get(fileIO)).as("Presigner is lazy").isNull(); + Class contextClass = host.loadClass(CatalogContext.class.getName()); + Object context = + contextClass.getMethod("create", options.getClass()).invoke(null, options); + fileIOClass.getMethod("configure", contextClass).invoke(fileIO, context); + assertThat(presignerField.get(fileIO)) + .as("Configuration does not load the plugin") + .isNull(); + Class pathClass = host.loadClass(Path.class.getName()); + Class descriptorClass = host.loadClass(BlobDescriptor.class.getName()); + Object tableRoot = + pathClass.getConstructor(String.class).newInstance("oss://bucket/table"); + Object descriptor = + descriptorClass + .getConstructor(String.class, long.class, long.class) + .newInstance("oss://bucket/table/data/file", 10L, 20L); + Method presign = + fileIOClass.getMethod( + "createBlobPresignedUrl", pathClass, descriptorClass, Duration.class); + for (int attempt = 0; attempt < 2; attempt++) { + assertThatThrownBy( + () -> presign.invoke(fileIO, tableRoot, descriptor, Duration.ZERO)) + .isInstanceOf(InvocationTargetException.class) + .hasCauseInstanceOf(IOException.class) + .hasStackTraceContaining("positive whole seconds") + .hasStackTraceContaining(PRESIGNER_IMPLEMENTATION + ".create") + .hasStackTraceContaining("org.apache.paimon.oss.OSSBlobPresigner.create"); + assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(host); + } + Class implementation = presignerField.get(fileIO).getClass(); + assertThat(implementation.getName()).isEqualTo(PRESIGNER_IMPLEMENTATION); + return implementation; + } finally { + fileIOClass.getMethod("close").invoke(fileIO); + assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(host); + } + } + + private static void assertGson(ClassLoader loader) throws Exception { + Class gsonClass = loader.loadClass("com.google.gson.Gson"); + Object gson = gsonClass.getConstructor().newInstance(); + assertThat( + gsonClass + .getMethod("toJson", Object.class) + .invoke(gson, Collections.singletonMap("plugin", 1))) + .isEqualTo("{\"plugin\":1}"); + } + + private static void assertBlobClient( + Class implementation, Class optionsClass, Object options, ClassLoader plugin) + throws Exception { + Method createClient = implementation.getDeclaredMethod("createBlobClient", optionsClass); + createClient.setAccessible(true); + Class clientClass = plugin.loadClass("com.aliyun.oss.OSSClient"); + assertThat(clientClass.getClassLoader()).isSameAs(plugin); + Object client = createClient.invoke(null, options); + try { + assertThat(client.getClass()).isSameAs(clientClass); + assertThat(clientClass.getMethod("getEndpoint").invoke(client)) + .isEqualTo(URI.create("https://oss.example.com")); + Object operation = clientClass.getMethod("getObjectOperation").invoke(client); + assertThat(operation.getClass().getMethod("getRegion").invoke(operation)) + .isEqualTo("cn-hangzhou"); + URL signedUrl = + (URL) + clientClass + .getMethod( + "generatePresignedUrl", + String.class, + String.class, + Date.class) + .invoke( + client, + "bucket", + "object", + new Date(System.currentTimeMillis() + 60_000)); + assertThat(signedUrl.getProtocol()).isEqualTo("https"); + assertThat(signedUrl.getHost()).isEqualTo("bucket.oss.example.com"); + assertThat(signedUrl.getPath()).isEqualTo("/object"); + assertThat(signedUrl.getQuery()) + .contains( + "OSSAccessKeyId=access-key", + "Signature=", + "security-token=security-token"); + } finally { + client.getClass().getMethod("shutdown").invoke(client); + } + } + + private static void assertPluginResources(ClassLoader host, ClassLoader plugin) + throws Exception { + for (String resource : Arrays.asList("versioninfo.properties", "common.properties")) { + URL pluginResource = plugin.getResource(resource); + assertThat(pluginResource).isNotNull(); + assertThat(pluginResource.toExternalForm()) + .contains("!/" + PLUGIN_DIRECTORY + resource); + assertThat(Collections.list(plugin.getResources(resource))) + .containsExactly(pluginResource); + } + assertThat(resourceProperties(host, "versioninfo.properties").getProperty("version")) + .isEqualTo("host-version"); + assertThat(resourceProperties(host, "common.properties").getProperty("ConnectionError")) + .isEqualTo("host-error"); + String version = + resourceProperties(plugin, "versioninfo.properties").getProperty("version"); + assertThat(version).isNotBlank().isNotIn("host-version", "unknown-version"); + assertThat( + plugin.loadClass("com.aliyun.oss.common.utils.VersionInfoUtils") + .getMethod("getVersion") + .invoke(null)) + .isEqualTo(version); + Class managerClass = plugin.loadClass("com.aliyun.oss.common.utils.ResourceManager"); + Object manager = + managerClass + .getMethod("getInstance", String.class, Locale.class) + .invoke(null, "common", Locale.ROOT); + String message = + resourceProperties(plugin, "common.properties").getProperty("ConnectionError"); + assertThat(message).isNotBlank().isNotEqualTo("host-error"); + assertThat( + managerClass + .getMethod("getString", String.class) + .invoke(manager, "ConnectionError")) + .isEqualTo(message); + } + + private static Properties resourceProperties(ClassLoader loader, String resource) + throws IOException { + Properties properties = new Properties(); + try (InputStream stream = loader.getResourceAsStream(resource)) { + assertThat(stream).as(resource).isNotNull(); + properties.load(stream); + } + return properties; + } +}