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

roryqi 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 2e256b3033 [#12367] test(iceberg): add a MinIO container for 
credential vending ITs (#12410)
2e256b3033 is described below

commit 2e256b30336b5feb9987b69113e0a37cf7c70a2f
Author: Bharath Krishna <[email protected]>
AuthorDate: Tue Aug 11 22:27:27 2026 -0700

    [#12367] test(iceberg): add a MinIO container for credential vending ITs 
(#12410)
    
    ### What changes were proposed in this pull request?
    
    Adds a MinIO-backed integration test so Iceberg credential vending is
    covered by CI.
    
    - `MinIOContainer` in `integration-test-common`, plus
    `ContainerSuite.startMinIOContainer()` / `getMinIOContainer()`.
    - `IcebergRESTMinIOTokenAuthorizationIT`, a subclass of
    `IcebergRESTCloudTokenAuthorizationBaseIT` tagged
    `gravitino-docker-test` rather than gated on cloud credentials, so its
    three test methods run in CI.
    
    Deploy mode needed two things from the base class:
    
    - `setupCloudBundles()` has to run before
    `super.startIntegrationTest()`, because the server resolves `S3FileIO`
    from its classpath while starting. The javadoc said the opposite and is
    corrected.
    - The bundle is installed into `catalogs/lakehouse-iceberg/libs/` as
    well as `iceberg-rest-server/libs/`. The catalog is loaded through
    `IsolatedClassLoader` and ships `iceberg-aws` without the AWS SDK, so
    `S3FileIO` resolved and then failed with `NoClassDefFoundError`. This is
    the layout described in `gravitino-server-config.md` and
    `credential-vending.md`. `gravitino-iceberg-aws-bundle` is built locally
    and shades both the Iceberg AWS bundle and the Gravitino credential
    providers, so nothing is downloaded at test time.
    
    ### Why are the changes needed?
    
    Credential vending ITs are gated by `@EnabledIfEnvironmentVariable(named
    = "GRAVITINO_TEST_CLOUD_IT", matches = "true")`, which nothing sets, and
    no workflow supplies cloud credentials. Whether a caller receives a
    writable or a read-only storage credential therefore has no automated
    protection against regressions.
    
    MinIO enforces the session policy attached to an `AssumeRole` request,
    so that behaviour is observable without a cloud account.
    
    Fix: #12367
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Test only.
    
    ### How was this patch tested?
    
    New test, run locally in both modes:
    
    ```
    ./gradlew :iceberg:iceberg-rest-server:test --tests 
"*IcebergRESTMinIOTokenAuthorizationIT*" \
      -PskipTests -PtestMode=embedded -PskipDockerTests=false
    
    ./gradlew :iceberg:iceberg-rest-server:test --tests 
"*IcebergRESTMinIOTokenAuthorizationIT*" \
      -PskipTests -PtestMode=deploy -PskipDockerTests=false
    ```
    
    All three tests pass in each mode: a caller with `MODIFY_TABLE` can
    write with the vended credential, and a caller with only `SELECT_TABLE`
    is rejected at the storage layer when it attempts to write.
---
 .../IcebergRESTCloudTokenAuthorizationBaseIT.java  |   5 +-
 .../test/IcebergRESTMinIOTokenAuthorizationIT.java | 129 ++++++++++++++++++
 .../integration/test/container/ContainerSuite.java |  25 ++++
 .../integration/test/container/MinIOContainer.java | 148 +++++++++++++++++++++
 4 files changed, 305 insertions(+), 2 deletions(-)

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 c49cf9e8df..83b14fc70d 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
@@ -106,8 +106,9 @@ public abstract class 
IcebergRESTCloudTokenAuthorizationBaseIT extends IcebergAu
 
   /**
    * Sets up cloud-specific bundle JARs by downloading and copying them. This 
method should be
-   * called from subclass {@code startIntegrationTest()} methods after calling 
{@code
-   * super.startIntegrationTest()}.
+   * called from subclass {@code startIntegrationTest()} methods before 
calling {@code
+   * super.startIntegrationTest()}, because the server resolves the cloud 
{@code FileIO} from its
+   * classpath while starting.
    *
    * <p>Skips setup if running in embedded mode.
    */
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/IcebergRESTMinIOTokenAuthorizationIT.java
new file mode 100644
index 0000000000..fe9dd561c0
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTMinIOTokenAuthorizationIT.java
@@ -0,0 +1,129 @@
+/*
+ * 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.util.HashMap;
+import java.util.Map;
+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.util.BaseIT;
+import org.apache.gravitino.integration.test.util.ITUtils;
+import org.apache.gravitino.storage.S3Properties;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * 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.
+ */
+@Tag("gravitino-docker-test")
+public class IcebergRESTMinIOTokenAuthorizationIT extends 
IcebergRESTCloudTokenAuthorizationBaseIT {
+
+  private static final String BUCKET_NAME = "gravitino-minio-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;
+  private String warehouse;
+
+  @BeforeAll
+  public void startIntegrationTest() throws Exception {
+    containerSuite.startMinIOContainer();
+    MinIOContainer minIOContainer = containerSuite.getMinIOContainer();
+    minIOContainer.createBucket(BUCKET_NAME);
+    this.s3Endpoint = minIOContainer.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
+    // in place before it starts.
+    setupCloudBundles();
+
+    super.startIntegrationTest();
+
+    // A sibling IT may have left this schema behind in the shared Iceberg 
JDBC backend.
+    if (!catalogClientWithAllPrivilege.asSchemas().schemaExists(SCHEMA_NAME)) {
+      catalogClientWithAllPrivilege.asSchemas().createSchema(SCHEMA_NAME, 
"test", new HashMap<>());
+    }
+  }
+
+  @AfterAll
+  public void stopIntegrationTest() throws IOException, InterruptedException {
+    // super drops the metalake, so it has to run even when setup failed part 
way through.
+    try {
+      // The Iceberg JDBC backend is shared with sibling ITs; Iceberg has no 
cascading drop.
+      clearTable();
+      catalogClientWithAllPrivilege.asSchemas().dropSchema(SCHEMA_NAME, false);
+    } finally {
+      super.stopIntegrationTest();
+    }
+  }
+
+  @Override
+  public Map<String, String> getCustomProperties() {
+    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_ENDPOINT, s3Endpoint);
+    configMap.put(S3Properties.GRAVITINO_S3_STS_ENDPOINT, s3Endpoint);
+    // MinIO 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 String getCloudProviderName() {
+    return "minio";
+  }
+
+  @Override
+  protected void downloadCloudBundleJar() {
+    // gravitino-iceberg-aws-bundle already shades the Iceberg AWS bundle and 
the Gravitino
+    // credential providers, so it is built locally and nothing has to be 
fetched.
+  }
+
+  @Override
+  protected void copyCloudBundleJar() {
+    String gravitinoHome = System.getenv("GRAVITINO_HOME");
+    // The REST server and the lakehouse-iceberg catalog resolve S3FileIO 
through separate
+    // classloaders, so each needs its own copy.
+    BaseIT.copyBundleJarsToDirectory(
+        BUNDLE_NAME, ITUtils.joinPath(gravitinoHome, "iceberg-rest-server", 
"libs"));
+    BaseIT.copyBundleJarsToDirectory(
+        BUNDLE_NAME, ITUtils.joinPath(gravitinoHome, "catalogs", 
"lakehouse-iceberg", "libs"));
+  }
+}
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 b2b0ae50da..384243edf8 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
@@ -89,6 +89,8 @@ public class ContainerSuite implements Closeable {
 
   private static volatile GravitinoLocalStackContainer 
gravitinoLocalStackContainer;
 
+  private static volatile MinIOContainer minIOContainer;
+
   /**
    * We can share the same Hive container as Hive container with S3 contains 
the following
    * differences: 1. Configuration of S3 and corresponding environment 
variables 2. The Hive
@@ -677,6 +679,29 @@ public class ContainerSuite implements Closeable {
     return gravitinoLocalStackContainer;
   }
 
+  public void startMinIOContainer() {
+    ITUtils.cleanDisk();
+    if (minIOContainer == null) {
+      synchronized (ContainerSuite.class) {
+        if (minIOContainer == null) {
+          MinIOContainer.Builder builder = 
MinIOContainer.builder().withNetwork(network);
+          MinIOContainer 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);
+          }
+          minIOContainer = container;
+        }
+      }
+    }
+  }
+
+  public MinIOContainer getMinIOContainer() {
+    return minIOContainer;
+  }
+
   public HiveContainer getHiveContainerWithS3() {
     return hiveContainerWithS3;
   }
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
new file mode 100644
index 0000000000..114ef335cb
--- /dev/null
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MinIOContainer.java
@@ -0,0 +1,148 @@
+/*
+ * 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);
+
+  public static final String DEFAULT_IMAGE = 
"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);
+    }
+  }
+}

Reply via email to