This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new d6f034d85e [#13154] test: Replace MinIO credential-vending fixture 
with RustFS (#13155)
d6f034d85e is described below

commit d6f034d85e40ac5f45d07ff5ade091ea3986a324
Author: Nevin Zheng <[email protected]>
AuthorDate: Mon Sep 14 23:00:32 2026 -0700

    [#13154] test: Replace MinIO credential-vending fixture with RustFS (#13155)
    
    ### What changes were proposed in this pull request?
    
    Replace the shared MinIO fixture with RustFS **1.0.0-rc.6** for fileset
    connection testing and Iceberg credential vending. The fixture consumes
    one version-and-digest pin from a packaged Docker Compose resource,
    waits for signed S3 readiness, and creates buckets through the AWS SDK.
    
    Preserve the Hadoop and Spark/S3FileIO tests, strengthen Spark
    write-denial assertions to require `403 AccessDenied`, and add focused
    checks of Gravitino's generated inline STS policy: read-only sessions,
    object and bucket boundaries, both listing APIs, Hadoop's deliberate
    bare-prefix behavior, deletion, and multipart upload.
    
    Image index:
    
`rustfs/rustfs:1.0.0-rc.6@sha256:97171b3d72cd47dc81000f92ea84de25608bfc35a94c965501afaeb5d99f6035`.
    Published `linux/amd64` and `linux/arm64` manifests were verified.
    
    ### Why are the changes needed?
    
    Move these tests beyond the MinIO image-availability workaround in
    #13112 while retaining their storage-authorization coverage. Related to
    #13154; automated image updates and vulnerability reporting are handled
    in a separate follow-up PR.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This changes test infrastructure only.
    
    RustFS is a release candidate. These checks establish
    caller-plus-inline-policy enforcement, not AWS named-role trust or
    external-ID enforcement. The generated policy does not grant
    `AbortMultipartUpload`; the focused multipart test uses root credentials
    only to clean up unfinished uploads after failure. Further boundaries
    and commands are documented in [the fixture
    
README](https://github.com/nevzheng/gravitino/blob/ffd21dd5ca8dedcca023731adb2aa5809889b566/integration-test-common/README-rustfs.md).
    
    ### How was this patch tested?
    
    On Corretto 17.0.19 and Docker 29.4/OrbStack with the pinned
    `linux/arm64` image:
    
    - Image-pin parsing: **2 passed**.
    - Signed STS policy and multipart probes: **5 passed**.
    - Spark/S3FileIO owner, modify, select-only, and active-role tests: **4
    passed**.
    - Hadoop fileset catalog connection: **1 passed**.
    - Changed-module Spotless checks and `git diff --check` passed.
    
    Native Linux amd64 and deploy-mode execution remain for the existing
    backend CI matrix. This Mac's build-output redirection needed its
    documented compatibility switch because the existing catalog loader
    expects module-local `build/resources/main` paths.
    
    ```shell
    ./gradlew :catalogs:catalog-lakehouse-iceberg:jar \
      :catalogs:catalog-fileset:jar :catalogs:hadoop-common:jar \
      -DcentralBuildOutputs.disabled=true
    ./gradlew :integration-test-common:test \
      --tests 
org.apache.gravitino.integration.test.container.TestRustFSContainer \
      :catalogs:catalog-fileset:test \
      --tests 
org.apache.gravitino.catalog.fileset.integration.test.FilesetS3TokenConnectionIT
 \
      :iceberg:iceberg-rest-server:test \
      --tests 
org.apache.gravitino.iceberg.integration.test.IcebergRESTRustFSTokenAuthorizationIT
 \
      --tests org.apache.gravitino.iceberg.integration.test.RustFSS3TokenIT \
      -PskipDockerTests=false -DcentralBuildOutputs.disabled=true
    ```
---
 catalogs/catalog-fileset/build.gradle.kts          |   2 +
 .../test/FilesetS3TokenConnectionIT.java           |  23 +-
 iceberg/iceberg-rest-server/build.gradle.kts       |   2 +
 .../IcebergRESTCloudTokenAuthorizationBaseIT.java  |  17 +-
 ... => IcebergRESTRustFSTokenAuthorizationIT.java} |  56 ++--
 .../iceberg/integration/test/RustFSS3TokenIT.java  | 313 +++++++++++++++++++++
 integration-test-common/README-rustfs.md           |  91 ++++++
 integration-test-common/build.gradle.kts           |   1 +
 .../integration/test/container/ContainerSuite.java |  28 +-
 .../integration/test/container/MinIOContainer.java | 149 ----------
 .../test/container/RustFSContainer.java            | 214 ++++++++++++++
 .../test/container/TestRustFSContainer.java        |  51 ++++
 .../src/test/resources/docker-compose-rustfs.yml   |  22 ++
 13 files changed, 771 insertions(+), 198 deletions(-)

diff --git a/catalogs/catalog-fileset/build.gradle.kts 
b/catalogs/catalog-fileset/build.gradle.kts
index ced821091f..d2879efcbb 100644
--- a/catalogs/catalog-fileset/build.gradle.kts
+++ b/catalogs/catalog-fileset/build.gradle.kts
@@ -79,6 +79,8 @@ dependencies {
   testImplementation(libs.testcontainers)
   testImplementation(libs.testcontainers.mysql)
 
+  // The RustFS fixture uses the S3 SDK v2; Hadoop's AWS bundle supplies the 
S3 SDK v1.
+  testRuntimeOnly(libs.aws.s3)
   testRuntimeOnly(libs.junit.jupiter.engine)
 }
 
diff --git 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/integration/test/FilesetS3TokenConnectionIT.java
 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/integration/test/FilesetS3TokenConnectionIT.java
index 86403dbd14..3e5b56f90f 100644
--- 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/integration/test/FilesetS3TokenConnectionIT.java
+++ 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/integration/test/FilesetS3TokenConnectionIT.java
@@ -29,7 +29,7 @@ import org.apache.gravitino.Catalog;
 import org.apache.gravitino.client.GravitinoMetalake;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.S3TokenCredential;
-import org.apache.gravitino.integration.test.container.MinIOContainer;
+import org.apache.gravitino.integration.test.container.RustFSContainer;
 import org.apache.gravitino.integration.test.util.BaseIT;
 import org.apache.gravitino.integration.test.util.GravitinoITUtils;
 import org.apache.gravitino.storage.S3Properties;
@@ -38,13 +38,10 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 
-/** Tests an existing Fileset catalog connection with S3 temporary credentials 
against MinIO. */
+/** Tests an existing Fileset catalog connection with S3 temporary credentials 
against RustFS. */
 @Tag("gravitino-docker-test")
 public class FilesetS3TokenConnectionIT extends BaseIT {
 
-  private static final String ROLE_ARN = "arn:minio:iam:::role/test";
-  private static final String REGION = "us-east-1";
-
   private final String bucketName =
       "fileset-connection-" + UUID.randomUUID().toString().replace("-", "");
   private final String metalakeName = 
GravitinoITUtils.genRandomName("fileset_connection_metalake");
@@ -57,25 +54,25 @@ public class FilesetS3TokenConnectionIT extends BaseIT {
   @BeforeAll
   public void startIntegrationTest() throws Exception {
     copyBundleJarsToHadoop("aws-bundle");
-    containerSuite.startMinIOContainer();
-    MinIOContainer minIOContainer = containerSuite.getMinIOContainer();
-    minIOContainer.createBucket(bucketName);
+    containerSuite.startRustFSContainer();
+    RustFSContainer rustFSContainer = containerSuite.getRustFSContainer();
+    rustFSContainer.createBucket(bucketName);
 
     super.startIntegrationTest();
 
     client.createMetalake(metalakeName, "comment", new HashMap<>());
     metalake = client.loadMetalake(metalakeName);
 
-    String endpoint = minIOContainer.getS3Endpoint();
+    String endpoint = rustFSContainer.getS3Endpoint();
     Map<String, String> properties = new HashMap<>();
     properties.put(LOCATION, String.format("s3a://%s", bucketName));
     properties.put(FILESYSTEM_PROVIDERS, "s3");
     properties.put(
         CredentialConstants.CREDENTIAL_PROVIDERS, 
S3TokenCredential.S3_TOKEN_CREDENTIAL_TYPE);
-    properties.put(S3Properties.GRAVITINO_S3_REGION, REGION);
-    properties.put(S3Properties.GRAVITINO_S3_ACCESS_KEY_ID, 
MinIOContainer.ACCESS_KEY);
-    properties.put(S3Properties.GRAVITINO_S3_SECRET_ACCESS_KEY, 
MinIOContainer.SECRET_KEY);
-    properties.put(S3Properties.GRAVITINO_S3_ROLE_ARN, ROLE_ARN);
+    properties.put(S3Properties.GRAVITINO_S3_REGION, RustFSContainer.REGION);
+    properties.put(S3Properties.GRAVITINO_S3_ACCESS_KEY_ID, 
RustFSContainer.ACCESS_KEY);
+    properties.put(S3Properties.GRAVITINO_S3_SECRET_ACCESS_KEY, 
RustFSContainer.SECRET_KEY);
+    properties.put(S3Properties.GRAVITINO_S3_ROLE_ARN, 
RustFSContainer.ROLE_ARN);
     properties.put(S3Properties.GRAVITINO_S3_ENDPOINT, endpoint);
     properties.put(S3Properties.GRAVITINO_S3_STS_ENDPOINT, endpoint);
     properties.put(S3Properties.GRAVITINO_S3_PATH_STYLE_ACCESS, "true");
diff --git a/iceberg/iceberg-rest-server/build.gradle.kts 
b/iceberg/iceberg-rest-server/build.gradle.kts
index f69bb0ea66..20a8abe8a9 100644
--- a/iceberg/iceberg-rest-server/build.gradle.kts
+++ b/iceberg/iceberg-rest-server/build.gradle.kts
@@ -98,6 +98,8 @@ dependencies {
   }
 
   testImplementation(libs.awaitility)
+  // At runtime use the SDK supplied by iceberg-aws-bundle, keeping its S3 and 
STS versions aligned.
+  testCompileOnly(libs.aws.s3)
   testImplementation(libs.h2db)
   testImplementation(libs.mysql.driver)
   testImplementation(libs.postgresql.driver)
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTCloudTokenAuthorizationBaseIT.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTCloudTokenAuthorizationBaseIT.java
index c7912761b1..babce16fe7 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTCloudTokenAuthorizationBaseIT.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTCloudTokenAuthorizationBaseIT.java
@@ -42,6 +42,7 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
 
 /**
  * Abstract base class for Iceberg REST credential vending integration tests 
with cloud storage
@@ -172,8 +173,7 @@ public abstract class 
IcebergRESTCloudTokenAuthorizationBaseIT extends IcebergAu
         () -> sql("SELECT * FROM %s.%s.%s", SPARK_CATALOG_NAME, SCHEMA_NAME, 
tableName));
 
     grantSelectTableRole(tableName);
-    Assertions.assertThrows(
-        SparkException.class, () -> sql("INSERT INTO %s VALUES (1,1),(2,2)", 
tableName));
+    assertStorageWriteDenied(() -> sql("INSERT INTO %s VALUES (1,1),(2,2)", 
tableName));
     List<Object[]> rows = sql("SELECT * FROM %s", tableName);
     Assertions.assertEquals(0, rows.size());
 
@@ -233,8 +233,7 @@ public abstract class 
IcebergRESTCloudTokenAuthorizationBaseIT extends IcebergAu
 
     // Narrowed to the select-only role the caller gets a read-only 
credential, so the write fails
     // inside Spark instead of being rejected by Gravitino.
-    Assertions.assertThrows(
-        SparkException.class,
+    assertStorageWriteDenied(
         () ->
             sql(
                 "INSERT INTO %s.%s.%s VALUES (3,3)",
@@ -245,6 +244,16 @@ public abstract class 
IcebergRESTCloudTokenAuthorizationBaseIT extends IcebergAu
     Assertions.assertEquals(2, rows.size());
   }
 
+  /**
+   * Checks that a storage write fails inside Spark. Providers may 
additionally check the storage
+   * error code to distinguish policy denial from an unrelated client failure.
+   *
+   * @param operation the write operation to reject
+   */
+  protected void assertStorageWriteDenied(Executable operation) {
+    Assertions.assertThrows(SparkException.class, operation);
+  }
+
   /**
    * The narrowed catalog names this role in a static header, so it has to 
carry every privilege the
    * read path needs; narrowing deactivates the per-test USE_SCHEMA role.
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTMinIOTokenAuthorizationIT.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTRustFSTokenAuthorizationIT.java
similarity index 62%
rename from 
iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTMinIOTokenAuthorizationIT.java
rename to 
iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTRustFSTokenAuthorizationIT.java
index 5b7fb88bcd..3c58d5f847 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTMinIOTokenAuthorizationIT.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTRustFSTokenAuthorizationIT.java
@@ -24,30 +24,30 @@ import 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.S3TokenCredential;
 import org.apache.gravitino.integration.test.container.ContainerSuite;
-import org.apache.gravitino.integration.test.container.MinIOContainer;
+import org.apache.gravitino.integration.test.container.RustFSContainer;
 import org.apache.gravitino.integration.test.util.BaseIT;
 import org.apache.gravitino.integration.test.util.ITUtils;
 import org.apache.gravitino.storage.S3Properties;
+import org.apache.spark.SparkException;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.function.Executable;
+import software.amazon.awssdk.services.s3.model.S3Exception;
 
 /**
- * Runs the cloud credential vending tests against MinIO. MinIO enforces the 
session policy attached
- * to an {@code AssumeRole} request, so the read-only downgrade a caller 
receives without {@code
- * MODIFY_TABLE} is observable without a cloud account.
+ * Runs the cloud credential vending tests against RustFS. RustFS enforces the 
session policy
+ * attached to an {@code AssumeRole} request, so the read-only downgrade a 
caller receives without
+ * {@code MODIFY_TABLE} is observable without a cloud account.
  */
 @Tag("gravitino-docker-test")
-public class IcebergRESTMinIOTokenAuthorizationIT extends 
IcebergRESTCloudTokenAuthorizationBaseIT {
+public class IcebergRESTRustFSTokenAuthorizationIT
+    extends IcebergRESTCloudTokenAuthorizationBaseIT {
 
-  private static final String BUCKET_NAME = "gravitino-minio-it";
+  private static final String BUCKET_NAME = "gravitino-rustfs-it";
 
   private static final String BUNDLE_NAME = "iceberg-aws-bundle";
 
-  // MinIO does not resolve the account or resource part, but the SDK requires 
a well-formed ARN.
-  private static final String ROLE_ARN = "arn:minio:iam:::role/test";
-
-  private static final String REGION = "us-east-1";
-
   private final ContainerSuite containerSuite = ContainerSuite.getInstance();
 
   private String s3Endpoint;
@@ -55,10 +55,10 @@ public class IcebergRESTMinIOTokenAuthorizationIT extends 
IcebergRESTCloudTokenA
 
   @BeforeAll
   public void startIntegrationTest() throws Exception {
-    containerSuite.startMinIOContainer();
-    MinIOContainer minIOContainer = containerSuite.getMinIOContainer();
-    minIOContainer.createBucket(BUCKET_NAME);
-    this.s3Endpoint = minIOContainer.getS3Endpoint();
+    containerSuite.startRustFSContainer();
+    RustFSContainer rustFSContainer = containerSuite.getRustFSContainer();
+    rustFSContainer.createBucket(BUCKET_NAME);
+    this.s3Endpoint = rustFSContainer.getS3Endpoint();
     this.warehouse = String.format("s3://%s/test1", BUCKET_NAME);
 
     // In deploy mode the server resolves S3FileIO from its own classpath, so 
the bundle has to be
@@ -75,22 +75,36 @@ public class IcebergRESTMinIOTokenAuthorizationIT extends 
IcebergRESTCloudTokenA
     Map<String, String> configMap = new HashMap<>();
     configMap.put(
         CredentialConstants.CREDENTIAL_PROVIDERS, 
S3TokenCredential.S3_TOKEN_CREDENTIAL_TYPE);
-    configMap.put(S3Properties.GRAVITINO_S3_REGION, REGION);
-    configMap.put(S3Properties.GRAVITINO_S3_ACCESS_KEY_ID, 
MinIOContainer.ACCESS_KEY);
-    configMap.put(S3Properties.GRAVITINO_S3_SECRET_ACCESS_KEY, 
MinIOContainer.SECRET_KEY);
-    configMap.put(S3Properties.GRAVITINO_S3_ROLE_ARN, ROLE_ARN);
+    configMap.put(S3Properties.GRAVITINO_S3_REGION, RustFSContainer.REGION);
+    configMap.put(S3Properties.GRAVITINO_S3_ACCESS_KEY_ID, 
RustFSContainer.ACCESS_KEY);
+    configMap.put(S3Properties.GRAVITINO_S3_SECRET_ACCESS_KEY, 
RustFSContainer.SECRET_KEY);
+    configMap.put(S3Properties.GRAVITINO_S3_ROLE_ARN, 
RustFSContainer.ROLE_ARN);
     configMap.put(S3Properties.GRAVITINO_S3_ENDPOINT, s3Endpoint);
     configMap.put(S3Properties.GRAVITINO_S3_STS_ENDPOINT, s3Endpoint);
-    // MinIO serves buckets as a path segment rather than a subdomain.
+    // RustFS serves buckets as a path segment rather than a subdomain.
     configMap.put(S3Properties.GRAVITINO_S3_PATH_STYLE_ACCESS, "true");
     configMap.put(IcebergConstants.IO_IMPL, 
"org.apache.iceberg.aws.s3.S3FileIO");
     configMap.put(IcebergConstants.WAREHOUSE, warehouse);
     return configMap;
   }
 
+  @Override
+  protected void assertStorageWriteDenied(Executable operation) {
+    SparkException error = Assertions.assertThrows(SparkException.class, 
operation);
+    Throwable cause = error;
+    while (cause != null && !(cause instanceof S3Exception)) {
+      cause = cause.getCause();
+    }
+    Assertions.assertNotNull(
+        cause, "Expected an S3 authorization failure in the Spark cause 
chain");
+    S3Exception denial = (S3Exception) cause;
+    Assertions.assertEquals(403, denial.statusCode());
+    Assertions.assertEquals("AccessDenied", 
denial.awsErrorDetails().errorCode());
+  }
+
   @Override
   protected String getCloudProviderName() {
-    return "minio";
+    return "rustfs";
   }
 
   @Override
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/RustFSS3TokenIT.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/RustFSS3TokenIT.java
new file mode 100644
index 0000000000..ebea11bce8
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/RustFSS3TokenIT.java
@@ -0,0 +1,313 @@
+/*
+ * 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.gravitino.iceberg.integration.test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.apache.gravitino.credential.CredentialConstants;
+import org.apache.gravitino.credential.PathBasedCredentialContext;
+import org.apache.gravitino.credential.S3TokenCredential;
+import org.apache.gravitino.integration.test.container.RustFSContainer;
+import org.apache.gravitino.s3.credential.S3TokenGenerator;
+import org.apache.gravitino.storage.S3Properties;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentials;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CompletedPart;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+/** Exercises Gravitino's generated inline STS policies through signed 
requests to RustFS. */
+@Tag("gravitino-docker-test")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class RustFSS3TokenIT {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(RustFSS3TokenIT.class);
+
+  private final String bucket = "gravitino-policy-" + UUID.randomUUID();
+  private final String otherBucket = "gravitino-other-" + UUID.randomUUID();
+  private RustFSContainer container;
+  private S3Client rootClient;
+
+  @BeforeAll
+  void startStorage() {
+    container = RustFSContainer.builder().build();
+    container.start();
+    container.createBucket(bucket);
+    container.createBucket(otherBucket);
+    rootClient =
+        client(AwsBasicCredentials.create(RustFSContainer.ACCESS_KEY, 
RustFSContainer.SECRET_KEY));
+    for (String key : List.of("read/file", "write/file", "write_sibling/file", 
"other/file")) {
+      put(rootClient, bucket, key);
+    }
+    put(rootClient, otherBucket, "write/file");
+  }
+
+  @AfterAll
+  void stopStorage() {
+    if (rootClient != null) {
+      rootClient.close();
+    }
+    if (container != null) {
+      container.close();
+    }
+  }
+
+  @Test
+  void testObjectResourcesAndReadOnlyPermissions() throws IOException {
+    try (S3Client scoped = scopedClient(false)) {
+      Assertions.assertEquals("data", read(scoped, bucket, "read/file"));
+      Assertions.assertEquals("data", read(scoped, bucket, "write/file"));
+      put(scoped, bucket, "write/new");
+      Assertions.assertEquals("data", read(scoped, bucket, "write/new"));
+      scoped.deleteObject(request -> request.bucket(bucket).key("write/new"));
+      Assertions.assertTrue(
+          rootClient
+              .listObjectsV2(request -> 
request.bucket(bucket).prefix("write/new"))
+              .contents()
+              .isEmpty());
+
+      assertAccessDenied(() -> put(scoped, bucket, "read/new"));
+      assertAccessDenied(
+          () -> scoped.deleteObject(request -> 
request.bucket(bucket).key("read/file")));
+      for (String key : List.of("other/file", "write_sibling/file", "write")) {
+        assertAccessDenied(() -> read(scoped, bucket, key));
+        assertAccessDenied(() -> put(scoped, bucket, key));
+        assertAccessDenied(() -> scoped.deleteObject(request -> 
request.bucket(bucket).key(key)));
+      }
+      assertAccessDenied(() -> read(scoped, otherBucket, "write/file"));
+      assertAccessDenied(() -> put(scoped, otherBucket, "write/new"));
+      assertAccessDenied(
+          () -> scoped.deleteObject(request -> 
request.bucket(otherBucket).key("write/file")));
+      scoped.getBucketLocation(request -> request.bucket(bucket));
+      assertAccessDenied(() -> scoped.getBucketLocation(request -> 
request.bucket(otherBucket)));
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testListPrefixes(boolean includeLocationPrefix) throws IOException {
+    try (S3Client scoped = scopedClient(includeLocationPrefix)) {
+      Assertions.assertEquals(
+          List.of("read/file"),
+          scoped.listObjects(request -> 
request.bucket(bucket).prefix("read/")).contents().stream()
+              .map(S3Object::key)
+              .collect(Collectors.toList()));
+      Assertions.assertEquals(
+          List.of("read/file"),
+          scoped
+              .listObjectsV2(request -> request.bucket(bucket).prefix("read/"))
+              .contents()
+              .stream()
+              .map(S3Object::key)
+              .collect(Collectors.toList()));
+      scoped.listObjectsV2(request -> 
request.bucket(bucket).prefix("write/subdirectory/"));
+      for (String prefix : List.of("", "other/", "write_sibling/")) {
+        assertAccessDenied(
+            () -> scoped.listObjects(request -> 
request.bucket(bucket).prefix(prefix)));
+        assertAccessDenied(
+            () -> scoped.listObjectsV2(request -> 
request.bucket(bucket).prefix(prefix)));
+      }
+      assertAccessDenied(() -> scoped.listObjects(request -> 
request.bucket(bucket)));
+      assertAccessDenied(() -> scoped.listObjectsV2(request -> 
request.bucket(bucket)));
+      assertAccessDenied(
+          () -> scoped.listObjectsV2(request -> 
request.bucket(otherBucket).prefix("write/")));
+      if (includeLocationPrefix) {
+        // Hadoop's directory probe deliberately permits listing the bare 
location prefix. That
+        // also reveals sibling key names sharing it, but grants no access to 
their object data.
+        Assertions.assertTrue(
+            scoped
+                .listObjectsV2(request -> 
request.bucket(bucket).prefix("write"))
+                .contents()
+                .stream()
+                .anyMatch(object -> 
object.key().equals("write_sibling/file")));
+        scoped.listObjects(request -> request.bucket(bucket).prefix("write"));
+        assertAccessDenied(() -> read(scoped, bucket, "write_sibling/file"));
+      } else {
+        assertAccessDenied(
+            () -> scoped.listObjects(request -> 
request.bucket(bucket).prefix("write")));
+        assertAccessDenied(
+            () -> scoped.listObjectsV2(request -> 
request.bucket(bucket).prefix("write")));
+      }
+    }
+  }
+
+  @Test
+  void testReadOnlySession() throws IOException {
+    try (S3Client scoped = scopedClient(false, false)) {
+      Assertions.assertEquals("data", read(scoped, bucket, "read/file"));
+      Assertions.assertEquals("data", read(scoped, bucket, "write/file"));
+      scoped.listObjectsV2(request -> request.bucket(bucket).prefix("write/"));
+      assertAccessDenied(() -> put(scoped, bucket, "write/read-only"));
+      assertAccessDenied(
+          () -> scoped.deleteObject(request -> 
request.bucket(bucket).key("write/file")));
+      assertAccessDenied(
+          () ->
+              scoped.createMultipartUpload(
+                  request -> request.bucket(bucket).key("write/read-only")));
+    }
+  }
+
+  @Test
+  void testMultipartUploadWithGeneratedWritePolicy() throws IOException {
+    String key = "write/multipart";
+    try (S3Client scoped = scopedClient(false)) {
+      assertAccessDenied(
+          () ->
+              scoped.createMultipartUpload(
+                  request -> request.bucket(bucket).key("read/multipart")));
+      assertAccessDenied(
+          () ->
+              scoped.createMultipartUpload(
+                  request -> request.bucket(bucket).key("other/multipart")));
+      String uploadId =
+          scoped.createMultipartUpload(request -> 
request.bucket(bucket).key(key)).uploadId();
+      boolean completed = false;
+      try {
+        byte[] firstPart = new byte[5 * 1024 * 1024];
+        String firstETag =
+            scoped
+                .uploadPart(
+                    request -> 
request.bucket(bucket).key(key).uploadId(uploadId).partNumber(1),
+                    RequestBody.fromBytes(firstPart))
+                .eTag();
+        String secondETag =
+            scoped
+                .uploadPart(
+                    request -> 
request.bucket(bucket).key(key).uploadId(uploadId).partNumber(2),
+                    RequestBody.fromString("tail"))
+                .eTag();
+        scoped.completeMultipartUpload(
+            request ->
+                request
+                    .bucket(bucket)
+                    .key(key)
+                    .uploadId(uploadId)
+                    .multipartUpload(
+                        upload ->
+                            upload.parts(
+                                
CompletedPart.builder().partNumber(1).eTag(firstETag).build(),
+                                
CompletedPart.builder().partNumber(2).eTag(secondETag).build())));
+        completed = true;
+        Assertions.assertEquals(
+            firstPart.length + 4,
+            scoped
+                .getObjectAsBytes(request -> request.bucket(bucket).key(key))
+                .asByteArray()
+                .length);
+        scoped.deleteObject(request -> request.bucket(bucket).key(key));
+      } finally {
+        // Abort is not granted by Gravitino's generated policy. Root 
credentials clean up an
+        // unfinished upload if any assertion or request above fails.
+        if (!completed) {
+          try {
+            rootClient.abortMultipartUpload(
+                request -> request.bucket(bucket).key(key).uploadId(uploadId));
+          } catch (Exception cleanupFailure) {
+            LOG.warn(
+                "Failed to clean up multipart upload {} after a test failure",
+                uploadId,
+                cleanupFailure);
+          }
+        }
+      }
+    }
+  }
+
+  private S3Client scopedClient(boolean includeLocationPrefix) throws 
IOException {
+    return scopedClient(includeLocationPrefix, true);
+  }
+
+  private S3Client scopedClient(boolean includeLocationPrefix, boolean 
writable)
+      throws IOException {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(S3Properties.GRAVITINO_S3_REGION, RustFSContainer.REGION);
+    properties.put(S3Properties.GRAVITINO_S3_ACCESS_KEY_ID, 
RustFSContainer.ACCESS_KEY);
+    properties.put(S3Properties.GRAVITINO_S3_SECRET_ACCESS_KEY, 
RustFSContainer.SECRET_KEY);
+    properties.put(S3Properties.GRAVITINO_S3_ROLE_ARN, 
RustFSContainer.ROLE_ARN);
+    properties.put(S3Properties.GRAVITINO_S3_STS_ENDPOINT, 
container.getS3Endpoint());
+    properties.put(
+        CredentialConstants.S3_CREDENTIAL_LIST_LOCATION_PREFIX,
+        Boolean.toString(includeLocationPrefix));
+    try (S3TokenGenerator generator = new S3TokenGenerator()) {
+      generator.initialize(properties);
+      S3TokenCredential credential =
+          generator.generate(
+              new PathBasedCredentialContext(
+                  "rustfs-test",
+                  writable ? Set.of("s3://" + bucket + "/write") : Set.of(),
+                  Set.of("s3://" + bucket + "/read", "s3://" + bucket + 
"/write")));
+      Assertions.assertNotEquals(RustFSContainer.ACCESS_KEY, 
credential.accessKeyId());
+      Assertions.assertFalse(credential.sessionToken().isEmpty());
+      return client(
+          AwsSessionCredentials.create(
+              credential.accessKeyId(), credential.secretAccessKey(), 
credential.sessionToken()));
+    }
+  }
+
+  private S3Client client(AwsCredentials credentials) {
+    return S3Client.builder()
+        .endpointOverride(URI.create(container.getS3Endpoint()))
+        .region(Region.of(RustFSContainer.REGION))
+        .forcePathStyle(true)
+        .credentialsProvider(StaticCredentialsProvider.create(credentials))
+        .overrideConfiguration(
+            config ->
+                config
+                    .apiCallTimeout(Duration.ofSeconds(30))
+                    .apiCallAttemptTimeout(Duration.ofSeconds(10)))
+        .build();
+  }
+
+  private static void put(S3Client client, String bucket, String key) {
+    client.putObject(request -> request.bucket(bucket).key(key), 
RequestBody.fromString("data"));
+  }
+
+  private static String read(S3Client client, String bucket, String key) {
+    return client.getObjectAsBytes(request -> 
request.bucket(bucket).key(key)).asUtf8String();
+  }
+
+  private static void assertAccessDenied(Executable request) {
+    S3Exception error = Assertions.assertThrows(S3Exception.class, request);
+    Assertions.assertEquals(403, error.statusCode());
+    Assertions.assertEquals("AccessDenied", 
error.awsErrorDetails().errorCode());
+  }
+}
diff --git a/integration-test-common/README-rustfs.md 
b/integration-test-common/README-rustfs.md
new file mode 100644
index 0000000000..eb0afc3b80
--- /dev/null
+++ b/integration-test-common/README-rustfs.md
@@ -0,0 +1,91 @@
+<!--
+  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.
+-->
+
+# RustFS credential-vending test fixture
+
+The fileset connection and Iceberg credential-vending integration tests use
+RustFS instead of MinIO. The authoritative version and multi-platform digest
+are in 
[`src/test/resources/docker-compose-rustfs.yml`](src/test/resources/docker-compose-rustfs.yml).
+`RustFSContainer` reads this packaged resource directly; changing that image
+entry changes the image used by the tests. Keep exactly one unquoted image
+entry with a release tag and SHA-256 digest.
+
+The initial pin is RustFS **1.0.0-rc.6**, a release candidate. Its published
+image index includes `linux/amd64` and `linux/arm64`. The fixture uses the
+image's default startup command, waits for a successful signed S3 request,
+and creates buckets with the AWS SDK. It needs no MinIO client or additional
+setup container. Testcontainers publishes the S3/STS port for host JVMs,
+forwards container logs, and removes the container and its temporary data
+when the owning test or shared container suite closes.
+
+## Run the compatibility checks
+
+Use JDK 17 and a running Docker daemon. The image-pin unit tests and focused
+signed STS policy checks do not start a Gravitino server:
+
+```shell
+./gradlew :integration-test-common:test \
+  --tests org.apache.gravitino.integration.test.container.TestRustFSContainer \
+  :iceberg:iceberg-rest-server:test \
+  --tests org.apache.gravitino.iceberg.integration.test.RustFSS3TokenIT \
+  -PskipDockerTests=false
+```
+
+Build the catalog runtime before running the existing embedded client suites
+from a fresh checkout:
+
+```shell
+./gradlew :catalogs:catalog-lakehouse-iceberg:jar
+./gradlew :catalogs:catalog-fileset:test \
+  --tests 
org.apache.gravitino.catalog.fileset.integration.test.FilesetS3TokenConnectionIT
 \
+  :iceberg:iceberg-rest-server:test \
+  --tests 
org.apache.gravitino.iceberg.integration.test.IcebergRESTRustFSTokenAuthorizationIT
 \
+  -PskipDockerTests=false
+```
+
+The backend integration workflow also runs the affected suites in its existing
+Linux amd64 embedded/deploy matrix when the image resource changes. Deploy
+tests require the normal assembled Gravitino distribution.
+
+The focused tests obtain temporary credentials through `S3TokenGenerator`,
+using its actual inline policy and signed `AssumeRole` request. They check
+read/write/delete permissions, another bucket, adjacent object paths,
+`GetBucketLocation`, both listing APIs with allowed and denied prefixes,
+read-only sessions, and a completed two-part multipart upload. Denials must
+be S3 `403 AccessDenied` responses. The RustFS Spark tests also require that
+error in the cause chain for select-only and narrowed-active-role writes.
+
+## Compatibility boundaries
+
+- RustFS derives a session's permissions from its caller and inline policy.
+  The fixture's role ARN satisfies the SDK's request format; these tests do
+  not establish AWS named-role trust or external-ID enforcement.
+- Fileset policies deliberately allow the bare location prefix for Hadoop
+  directory probing. This permits listing sibling key names sharing that
+  prefix, while the object-resource policy still denies access to their
+  contents. The focused checks preserve this distinction.
+- Gravitino's generated write policy grants `PutObject` and `DeleteObject`;
+  it does not grant `AbortMultipartUpload`. The multipart test uses root
+  credentials to clean up an unfinished upload after a failure. Passing
+  these checks does not establish every S3 multipart edge case.
+- Image scanning cannot establish Rust dependency coverage when an image
+  lacks usable Rust package metadata. See the linked image-maintenance
+  follow-up for update detection, scan coverage, and maintainer triage.
+
+Related to [#13154](https://github.com/apache/gravitino/issues/13154).
diff --git a/integration-test-common/build.gradle.kts 
b/integration-test-common/build.gradle.kts
index a73ec0a627..7adeffc08b 100644
--- a/integration-test-common/build.gradle.kts
+++ b/integration-test-common/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
   testImplementation(project(":server"))
   testImplementation(project(":server-common"))
   testImplementation(libs.awaitility)
+  testImplementation(libs.aws.s3)
   testImplementation(libs.bundles.jetty)
   testImplementation(libs.bundles.jersey)
   testImplementation(libs.bundles.jwt)
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
index 05ecac0186..990a6b6f9e 100644
--- 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
@@ -93,7 +93,7 @@ public class ContainerSuite implements Closeable {
 
   private static volatile GravitinoLocalStackContainer 
gravitinoLocalStackContainer;
 
-  private static volatile MinIOContainer minIOContainer;
+  private static volatile RustFSContainer rustFSContainer;
 
   /**
    * We can share the same Hive container as Hive container with S3 contains 
the following
@@ -698,27 +698,33 @@ public class ContainerSuite implements Closeable {
     return gravitinoLocalStackContainer;
   }
 
-  public void startMinIOContainer() {
+  /** Starts the shared RustFS object store for S3 credential-vending tests. */
+  public void startRustFSContainer() {
     ITUtils.cleanDisk();
-    if (minIOContainer == null) {
+    if (rustFSContainer == null) {
       synchronized (ContainerSuite.class) {
-        if (minIOContainer == null) {
-          MinIOContainer.Builder builder = 
MinIOContainer.builder().withNetwork(network);
-          MinIOContainer container = closer.register(builder.build());
+        if (rustFSContainer == null) {
+          RustFSContainer.Builder builder = 
RustFSContainer.builder().withNetwork(network);
+          RustFSContainer container = closer.register(builder.build());
           try {
             container.start();
           } catch (Exception e) {
-            LOG.error("Failed to start MinIO container", e);
-            throw new RuntimeException("Failed to start MinIO container", e);
+            LOG.error("Failed to start RustFS container", e);
+            throw new RuntimeException("Failed to start RustFS container", e);
           }
-          minIOContainer = container;
+          rustFSContainer = container;
         }
       }
     }
   }
 
-  public MinIOContainer getMinIOContainer() {
-    return minIOContainer;
+  /**
+   * Returns the shared RustFS fixture after {@link #startRustFSContainer()}.
+   *
+   * @return the RustFS fixture
+   */
+  public RustFSContainer getRustFSContainer() {
+    return rustFSContainer;
   }
 
   public HiveContainer getHiveContainerWithS3() {
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MinIOContainer.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MinIOContainer.java
deleted file mode 100644
index 79c8252020..0000000000
--- 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MinIOContainer.java
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * 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.gravitino.integration.test.container;
-
-import static java.lang.String.format;
-import static org.awaitility.Awaitility.await;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import org.rnorth.ducttape.Preconditions;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.testcontainers.containers.Container;
-import org.testcontainers.containers.Network;
-
-/**
- * A MinIO container providing S3-compatible object storage. MinIO enforces 
the session policy of an
- * {@code AssumeRole} request, so it can exercise credential vending, 
including the read-only
- * downgrade, without a cloud account.
- */
-public class MinIOContainer extends BaseContainer {
-  public static final Logger LOG = 
LoggerFactory.getLogger(MinIOContainer.class);
-
-  // MinIO no longer publishes this image on Docker Hub; quay.io carries the 
same release.
-  public static final String DEFAULT_IMAGE = 
"quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z";
-  public static final String HOST_NAME = "gravitino-ci-minio";
-  public static final int PORT = 9000;
-  public static final String ACCESS_KEY = "minioadmin";
-  public static final String SECRET_KEY = "minioadmin123";
-
-  public static Builder builder() {
-    return new Builder();
-  }
-
-  private MinIOContainer(
-      String image,
-      String hostName,
-      Set<Integer> ports,
-      Map<String, String> extraHosts,
-      Map<String, String> filesToMount,
-      Map<String, String> envVars,
-      Optional<Network> network) {
-    super(image, hostName, ports, extraHosts, filesToMount, envVars, network);
-  }
-
-  @Override
-  protected void setupContainer() {
-    super.setupContainer();
-    // The image defines no default command; the server needs to be told where 
to keep its data.
-    container.withCommand("server", "/data");
-    withLogConsumer(new PrintingContainerLog(format("%-14s| ", 
"MinIOContainer")));
-  }
-
-  @Override
-  public void start() {
-    super.start();
-    Preconditions.check("MinIO container startup failed!", 
checkContainerStatus(5));
-  }
-
-  @Override
-  protected boolean checkContainerStatus(int retryLimit) {
-    // `mc alias set` contacts the server, so it only succeeds once MinIO is 
accepting requests.
-    await()
-        .atMost(30, TimeUnit.SECONDS)
-        .pollInterval(30 / retryLimit, TimeUnit.SECONDS)
-        .until(
-            () -> {
-              try {
-                return setLocalAlias().getExitCode() == 0;
-              } catch (Exception e) {
-                LOG.warn("MinIO is not ready yet", e);
-                return false;
-              }
-            });
-    return true;
-  }
-
-  /**
-   * Creates a bucket, so that a warehouse location inside it can be used.
-   *
-   * @param bucketName the bucket to create
-   */
-  public void createBucket(String bucketName) {
-    try {
-      setLocalAlias();
-      Container.ExecResult result = executeInContainer("mc", "mb", "local/" + 
bucketName);
-      if (result.getExitCode() != 0) {
-        throw new RuntimeException(
-            format("Failed to create bucket %s: %s", bucketName, 
result.getStderr()));
-      }
-    } catch (Exception e) {
-      throw new RuntimeException("Failed to create bucket " + bucketName, e);
-    }
-  }
-
-  private Container.ExecResult setLocalAlias() throws Exception {
-    return executeInContainer(
-        "mc", "alias", "set", "local", "http://localhost:"; + PORT, ACCESS_KEY, 
SECRET_KEY);
-  }
-
-  /**
-   * Returns the S3 endpoint of this container, reachable from the host once 
container addresses are
-   * routed.
-   *
-   * @return the endpoint, for example {@code http://10.20.30.5:9000}
-   */
-  public String getS3Endpoint() {
-    return format("http://%s:%d";, getContainerIpAddress(), PORT);
-  }
-
-  /** Builder for {@link MinIOContainer}. */
-  public static class Builder
-      extends BaseContainer.Builder<MinIOContainer.Builder, MinIOContainer> {
-
-    private Builder() {
-      this.image = DEFAULT_IMAGE;
-      this.hostName = HOST_NAME;
-      this.exposePorts = ImmutableSet.of(PORT);
-      this.envVars =
-          ImmutableMap.of("MINIO_ROOT_USER", ACCESS_KEY, 
"MINIO_ROOT_PASSWORD", SECRET_KEY);
-    }
-
-    @Override
-    public MinIOContainer build() {
-      return new MinIOContainer(
-          image, hostName, exposePorts, extraHosts, filesToMount, envVars, 
network);
-    }
-  }
-}
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/RustFSContainer.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/RustFSContainer.java
new file mode 100644
index 0000000000..7177198033
--- /dev/null
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/RustFSContainer.java
@@ -0,0 +1,214 @@
+/*
+ * 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.gravitino.integration.test.container;
+
+import static java.lang.String.format;
+import static org.awaitility.Awaitility.await;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.Network;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+
+/**
+ * A RustFS container providing S3-compatible object storage. RustFS enforces 
the session policy of
+ * an {@code AssumeRole} request, so it can exercise credential vending, 
including the read-only
+ * downgrade, without a cloud account.
+ */
+public class RustFSContainer extends BaseContainer {
+  private static final Logger LOG = 
LoggerFactory.getLogger(RustFSContainer.class);
+
+  /** The image pin consumed from the Docker Compose manifest tracked by image 
updates. */
+  public static final String DEFAULT_IMAGE = loadImage();
+
+  /** The container hostname on the shared integration-test network. */
+  public static final String HOST_NAME = "gravitino-ci-rustfs";
+
+  /** The S3 and STS port inside the container. */
+  public static final int PORT = 9000;
+
+  /** Root access key used only by this local test fixture. */
+  public static final String ACCESS_KEY = "rustfsadmin";
+
+  /** Root secret key used only by this local test fixture. */
+  public static final String SECRET_KEY = "rustfsadmin123";
+
+  /** Region used when signing requests to the fixture. */
+  public static final String REGION = "us-east-1";
+
+  /**
+   * Well-formed role identifier for the SDK. RustFS scopes sessions by caller 
and inline policy;
+   * this fixture does not emulate AWS named-role trust or external-ID 
enforcement.
+   */
+  public static final String ROLE_ARN = 
"arn:aws:iam::123456789012:role/gravitino-test";
+
+  /**
+   * Creates a builder with the default test configuration.
+   *
+   * @return the container builder
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private RustFSContainer(
+      String image,
+      String hostName,
+      Set<Integer> ports,
+      Map<String, String> extraHosts,
+      Map<String, String> filesToMount,
+      Map<String, String> envVars,
+      Optional<Network> network) {
+    super(image, hostName, ports, extraHosts, filesToMount, envVars, network);
+  }
+
+  @Override
+  protected void setupContainer() {
+    super.setupContainer();
+    withLogConsumer(new PrintingContainerLog(format("%-14s| ", 
"RustFSContainer")));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    checkContainerStatus(60);
+  }
+
+  @Override
+  protected boolean checkContainerStatus(int retryLimit) {
+    // A listening socket alone does not establish that IAM and the S3 API are 
initialized.
+    try (S3Client client = createS3Client()) {
+      await()
+          .atMost(60, TimeUnit.SECONDS)
+          .pollInterval(Math.max(1, 60 / retryLimit), TimeUnit.SECONDS)
+          .until(
+              () -> {
+                try {
+                  client.listBuckets();
+                  return true;
+                } catch (Exception e) {
+                  LOG.info("RustFS is not ready yet: {}", e.getMessage());
+                  return false;
+                }
+              });
+    }
+    return true;
+  }
+
+  /**
+   * Creates a bucket, so that a warehouse location inside it can be used.
+   *
+   * @param bucketName the bucket to create
+   */
+  public void createBucket(String bucketName) {
+    try (S3Client client = createS3Client()) {
+      client.createBucket(request -> request.bucket(bucketName));
+      client.headBucket(request -> request.bucket(bucketName));
+    }
+  }
+
+  /**
+   * Returns the S3 and STS endpoint reachable from the host through the 
published port.
+   *
+   * @return the endpoint, for example {@code http://localhost:32768}
+   */
+  public String getS3Endpoint() {
+    return format("http://%s:%d";, container.getHost(), getMappedPort(PORT));
+  }
+
+  static String imageFromCompose(String compose) {
+    // This single-service manifest is deliberately limited to one unquoted 
image reference.
+    // Fail closed if its structure changes, rather than silently using a 
different image pin.
+    Matcher matcher = Pattern.compile("(?m)^\\s+image: 
(\\S+)\\s*$").matcher(compose);
+    if (!matcher.find()) {
+      throw new IllegalArgumentException("RustFS Compose manifest must contain 
an image");
+    }
+    String image = matcher.group(1);
+    if (matcher.find() || 
!image.matches("rustfs/rustfs:[^@\\s]+@sha256:[0-9a-f]{64}")) {
+      throw new IllegalArgumentException(
+          "RustFS Compose manifest must contain one version and digest pin");
+    }
+    return image;
+  }
+
+  private static String loadImage() {
+    try (InputStream stream =
+        
RustFSContainer.class.getResourceAsStream("/docker-compose-rustfs.yml")) {
+      if (stream == null) {
+        throw new IllegalStateException("Missing docker-compose-rustfs.yml 
test resource");
+      }
+      return imageFromCompose(new String(stream.readAllBytes(), 
StandardCharsets.UTF_8));
+    } catch (IOException e) {
+      throw new IllegalStateException("Unable to read RustFS image pin", e);
+    }
+  }
+
+  private S3Client createS3Client() {
+    return S3Client.builder()
+        .endpointOverride(URI.create(getS3Endpoint()))
+        .region(Region.of(REGION))
+        .forcePathStyle(true)
+        .credentialsProvider(
+            
StaticCredentialsProvider.create(AwsBasicCredentials.create(ACCESS_KEY, 
SECRET_KEY)))
+        .overrideConfiguration(
+            config ->
+                config
+                    .apiCallTimeout(Duration.ofSeconds(5))
+                    .apiCallAttemptTimeout(Duration.ofSeconds(3)))
+        .build();
+  }
+
+  /** Builder for {@link RustFSContainer}. */
+  public static class Builder
+      extends BaseContainer.Builder<RustFSContainer.Builder, RustFSContainer> {
+
+    private Builder() {
+      this.image = DEFAULT_IMAGE;
+      this.hostName = HOST_NAME;
+      this.exposePorts = ImmutableSet.of(PORT);
+      this.envVars =
+          ImmutableMap.of(
+              "RUSTFS_ACCESS_KEY", ACCESS_KEY,
+              "RUSTFS_SECRET_KEY", SECRET_KEY,
+              "RUSTFS_CONSOLE_ENABLE", "false");
+    }
+
+    @Override
+    public RustFSContainer build() {
+      return new RustFSContainer(
+          image, hostName, exposePorts, extraHosts, filesToMount, envVars, 
network);
+    }
+  }
+}
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestRustFSContainer.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestRustFSContainer.java
new file mode 100644
index 0000000000..50e0379a7c
--- /dev/null
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestRustFSContainer.java
@@ -0,0 +1,51 @@
+/*
+ * 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.gravitino.integration.test.container;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests that the fixture consumes exactly one versioned, immutable image 
reference. */
+public class TestRustFSContainer {
+
+  @Test
+  void testImagePinFromPackagedResource() {
+    Assertions.assertTrue(
+        
RustFSContainer.DEFAULT_IMAGE.matches("rustfs/rustfs:[^@\\s]+@sha256:[0-9a-f]{64}"));
+  }
+
+  @Test
+  void testImagePinParsing() {
+    String manifest = "services:\n  rustfs:\n    image: " + 
RustFSContainer.DEFAULT_IMAGE + "\n";
+    Assertions.assertEquals(
+        RustFSContainer.DEFAULT_IMAGE, 
RustFSContainer.imageFromCompose(manifest));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> RustFSContainer.imageFromCompose(manifest + "    image: 
another/image:latest\n"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> RustFSContainer.imageFromCompose("services:\n  rustfs:\n"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> RustFSContainer.imageFromCompose("    image: 
rustfs/rustfs:latest\n"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> RustFSContainer.imageFromCompose("    image: 
rustfs/rustfs:1.0.0@sha256:bad\n"));
+  }
+}
diff --git 
a/integration-test-common/src/test/resources/docker-compose-rustfs.yml 
b/integration-test-common/src/test/resources/docker-compose-rustfs.yml
new file mode 100644
index 0000000000..cad91610a2
--- /dev/null
+++ b/integration-test-common/src/test/resources/docker-compose-rustfs.yml
@@ -0,0 +1,22 @@
+# 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.
+
+# RustFSContainer reads this authoritative image pin from its test resources.
+# Keep a single, unquoted image entry so fixture and image monitoring agree.
+services:
+  rustfs:
+    image: 
rustfs/rustfs:1.0.0-rc.6@sha256:97171b3d72cd47dc81000f92ea84de25608bfc35a94c965501afaeb5d99f6035

Reply via email to