This is an automated email from the ASF dual-hosted git repository.
jerry-024 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new de872c4df6 [filesystem] Isolate Jindo OSS presigning dependencies
(#9856)
de872c4df6 is described below
commit de872c4df69f2c03394e2758c3c55659e8f5888c
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Sep 16 15:20:09 2026 +0800
[filesystem] Isolate Jindo OSS presigning dependencies (#9856)
---
paimon-filesystems/paimon-jindo/pom.xml | 127 ++++-
.../apache/paimon/jindo/JindoBlobPresigner.java | 85 ++++
.../java/org/apache/paimon/jindo/JindoFileIO.java | 98 ++--
.../src/main/resources/META-INF/NOTICE | 32 ++
.../org/apache/paimon/jindo/JindoFileIOTest.java | 16 +-
.../org/apache/paimon/jindo/JindoPluginITCase.java | 522 +++++++++++++++++++++
6 files changed, 824 insertions(+), 56 deletions(-)
diff --git a/paimon-filesystems/paimon-jindo/pom.xml
b/paimon-filesystems/paimon-jindo/pom.xml
index e96a5ff63b..f70fe56d91 100644
--- a/paimon-filesystems/paimon-jindo/pom.xml
+++ b/paimon-filesystems/paimon-jindo/pom.xml
@@ -34,6 +34,7 @@
<properties>
<fs.oss.sdk.version>3.17.4</fs.oss.sdk.version>
<jindodata.version>6.9.1</jindodata.version>
+ <oss.plugin.directory>paimon-plugin-jindo-oss</oss.plugin.directory>
</properties>
<repositories>
@@ -55,6 +56,12 @@
<groupId>org.apache.paimon</groupId>
<artifactId>paimon-oss-impl</artifactId>
<version>${project.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>*</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
<dependency>
@@ -160,6 +167,9 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
+ <excludes>
+
<exclude>org/apache/paimon/jindo/JindoBlobPresigner*.class</exclude>
+ </excludes>
<archive>
<manifestEntries>
<Multi-Release>true</Multi-Release>
@@ -168,34 +178,121 @@
</configuration>
</plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-oss-presigner-implementation</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+
<outputDirectory>${project.build.outputDirectory}/${oss.plugin.directory}</outputDirectory>
+ <resources>
+ <resource>
+
<directory>${project.build.outputDirectory}</directory>
+ <includes>
+
<include>org/apache/paimon/jindo/JindoBlobPresigner*.class</include>
+ </includes>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>unpack-oss-runtime</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>unpack-dependencies</goal>
+ </goals>
+ <configuration>
+ <includeScope>runtime</includeScope>
+
<excludeArtifactIds>paimon-oss-impl,slf4j-api,jsr305</excludeArtifactIds>
+
<outputDirectory>${project.build.outputDirectory}/${oss.plugin.directory}</outputDirectory>
+
<excludes>META-INF/*.SF,META-INF/*.DSA,META-INF/*.RSA,META-INF/maven/**</excludes>
+ </configuration>
+ </execution>
+ <execution>
+ <id>unpack-oss-presigner</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>unpack</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.apache.paimon</groupId>
+ <artifactId>paimon-oss-impl</artifactId>
+ <version>${project.version}</version>
+ <overWrite>true</overWrite>
+
<outputDirectory>${project.build.outputDirectory}/${oss.plugin.directory}</outputDirectory>
+
<includes>org/apache/paimon/oss/OSSBlobPresigner*.class</includes>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>integration-tests</id>
+ <configuration>
+ <systemPropertyVariables>
+
<jindo.plugin.jar>${project.build.directory}/${project.build.finalName}.jar</jindo.plugin.jar>
+ </systemPropertyVariables>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<configuration>
<artifactSet>
<includes combine.children="append">
-
<include>org.apache.paimon:paimon-oss-impl</include>
- <include>com.aliyun.oss:aliyun-sdk-oss</include>
- <include>com.aliyun:*</include>
- <include>com.google.code.gson:gson</include>
- <include>commons-codec:commons-codec</include>
- <include>commons-logging:commons-logging</include>
- <include>io.opentracing:*</include>
- <include>org.apache.httpcomponents:*</include>
- <include>org.codehaus.jettison:jettison</include>
- <include>org.ini4j:ini4j</include>
- <include>org.jdom:jdom2</include>
- <include>stax:stax-api</include>
+ <include>*:*</include>
</includes>
+ <excludes>
+ <exclude>org.slf4j:slf4j-api</exclude>
+ <exclude>com.google.code.findbugs:jsr305</exclude>
+ </excludes>
</artifactSet>
<filters>
<filter>
-
<artifact>org.apache.paimon:paimon-oss-impl</artifact>
+ <artifact>*:*</artifact>
<includes>
-
<include>org/apache/paimon/oss/OSSBlobPresigner.class</include>
- <include>META-INF/versions/11/**</include>
+ <include>org/apache/paimon/jindo/**</include>
+ <include>${oss.plugin.directory}/**</include>
+ <include>META-INF/MANIFEST.MF</include>
+ <include>META-INF/LICENSE*</include>
+
<include>META-INF/licenses/LICENSE.jdom</include>
+
<include>META-INF/licenses/LICENSE.jaxb</include>
+ <include>META-INF/NOTICE*</include>
+ <include>META-INF/DEPENDENCIES</include>
+ <include>META-INF/maven/**</include>
+
<include>META-INF/services/org.apache.paimon.fs.FileIOLoader</include>
+
<include>META-INF/versions/11/javax/xml/bind/**</include>
</includes>
</filter>
+ <filter>
+
<artifact>org.apache.paimon:paimon-oss-impl</artifact>
+ <excludes>
+ <exclude>META-INF/NOTICE*</exclude>
+ </excludes>
+ </filter>
</filters>
</configuration>
</plugin>
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 0000000000..57e399e9d5
--- /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 1e4646d4b9..31e0d440d9 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.HadoopOptionsProvider;
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.dls.JindoDlsFileSystem;
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 class JindoFileIO extends HadoopCompliantFileIO
implements HadoopOptionsP
@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 @@ public class JindoFileIO extends HadoopCompliantFileIO
implements HadoopOptionsP
@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 class JindoFileIO extends HadoopCompliantFileIO
implements HadoopOptionsP
}
}
+ /** 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 0000000000..e5d564724e
--- /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 91406badb4..06b430a2a2 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.URI;
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 class JindoFileIOTest {
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 class JindoFileIOTest {
.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 class JindoFileIOTest {
"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 0000000000..7f4e2451ae
--- /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<String> 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<String> 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<String> 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<String> 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<URL> urls = new ArrayList<>();
+ urls.add(tempDir.toUri().toURL());
+ List<String> 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<String, String> 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;
+ }
+}