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);
+ }
+ }
+}