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 5c8f5bf9d2 [#9418] fix(iceberg): inject GCS FileIO token from
gcs-service-account-file (#12962)
5c8f5bf9d2 is described below
commit 5c8f5bf9d210b49d135896dd3c0fa64ebb065e24
Author: MaSai <[email protected]>
AuthorDate: Wed Sep 9 17:09:26 2026 +0800
[#9418] fix(iceberg): inject GCS FileIO token from gcs-service-account-file
(#12962)
### What changes were proposed in this pull request?
Iceberg's `GCSFileIO` does not understand Gravitino's
`gcs-service-account-file`.
When that property is set, load the service account at catalog
initialization and
inject Iceberg `gcs.oauth2.token` / `gcs.oauth2.token-expires-at` so
server-side
FileIO can authenticate (same property-injection style as S3/OSS/ADLS
key mapping).
Also update GCS docs that previously required
`GOOGLE_APPLICATION_CREDENTIALS`
even when the catalog property was set.
### Why are the changes needed?
`gcs-service-account-file` reached the credential provider (vending
worked) but not
FileIO. Table create wrote metadata with Application Default Credentials
and failed
with 401 unless `GOOGLE_APPLICATION_CREDENTIALS` was set on the process.
Fix: #9418
### Does this PR introduce _any_ user-facing change?
- Configuring `gcs-service-account-file` is sufficient for Iceberg GCS
FileIO;
`GOOGLE_APPLICATION_CREDENTIALS` is only a fallback when the property is
unset.
- No new public API or property keys.
### How was this patch tested?
```
./gradlew :iceberg:iceberg-common:test --tests
org.apache.gravitino.iceberg.common.utils.TestIcebergCatalogUtil -PskipITs
```
Made with [Cursor](https://cursor.com)
---------
Co-authored-by: Cursor <[email protected]>
---
.../lakehouse/iceberg/IcebergConstants.java | 14 +++
.../catalog/TestClassLoaderPoolIntegration.java | 14 ++-
docs/iceberg-rest-service.md | 9 +-
docs/lakehouse-iceberg-catalog.md | 9 +-
docs/security/credential-vending.md | 2 +-
iceberg/iceberg-common/build.gradle.kts | 2 +
.../iceberg/common/utils/IcebergCatalogUtil.java | 92 ++++++++++++++++++
.../common/utils/TestIcebergCatalogUtil.java | 70 ++++++++++++++
.../service/IcebergCatalogWrapperManager.java | 104 ++++++++++++++++++---
.../TestIcebergCatalogWrapperManagerForREST.java | 65 +++++++++++++
10 files changed, 358 insertions(+), 23 deletions(-)
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
index e7f97b6bdf..120cebb619 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
@@ -77,6 +77,20 @@ public class IcebergConstants {
public static final String AZURE_CLIENT_SECRET_TOKEN_CREDENTIAL_PROVIDER =
"org.apache.gravitino.iceberg.common.credential.AzureClientSecretTokenCredentialProvider";
+ /** Iceberg GCSFileIO OAuth2 access token property. */
+ public static final String ICEBERG_GCS_OAUTH2_TOKEN = "gcs.oauth2.token";
+
+ /** Iceberg GCSFileIO OAuth2 token expiry property (epoch millis). */
+ public static final String ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT =
"gcs.oauth2.token-expires-at";
+
+ /**
+ * Whether Iceberg GCSFileIO should refresh OAuth2 tokens via a credentials
endpoint. Defaults to
+ * true in Iceberg; Gravitino disables it when minting a token from {@code
+ * gcs-service-account-file} because that path has no table credentials
refresh endpoint.
+ */
+ public static final String ICEBERG_GCS_OAUTH2_REFRESH_CREDENTIALS_ENABLED =
+ "gcs.oauth2.refresh-credentials-enabled";
+
// Iceberg Table properties constants
public static final String COMMENT = "comment";
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
b/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
index 5589d41d53..988281a9a6 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.Config;
@@ -352,7 +353,18 @@ public class TestClassLoaderPoolIntegration {
catalogManager.close();
- // After close, the pool should be empty
+ // close() retires cached wrappers synchronously, but Caffeine's asMap()
is only weakly
+ // consistent: invalidateAll may still deliver an asynchronous
removal/retire for an entry
+ // the snapshot missed, which keeps the pooled ClassLoader alive until
that listener runs.
+ long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (pool.size() > 0 && System.nanoTime() < deadlineNs) {
+ try {
+ Thread.sleep(10L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
Assertions.assertEquals(0, pool.size());
catalogManager = null;
}
diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md
index 8339d69268..f3c55cdafd 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -577,16 +577,17 @@ Please set the `gravitino.iceberg-rest.warehouse`
parameter to `oss://{bucket_na
Supports using static GCS credential file or generating GCS token to access
GCS data.
-| Configuration item | Description
| Default value | Required |
-|----------------------------------|------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|----------|
-| `gravitino.iceberg-rest.io-impl` | The IO implementation for `FileIO` in
Iceberg. Set it to `org.apache.iceberg.gcp.gcs.GCSFileIO` to explicitly use
GCSFileIO. | `org.apache.iceberg.io.ResolvingFileIO` | No |
+| Configuration item | Description
| Default value | Required |
+|---------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|----------|
+| `gravitino.iceberg-rest.io-impl` | The IO implementation
for `FileIO` in Iceberg. Set it to `org.apache.iceberg.gcp.gcs.GCSFileIO` to
explicitly use GCSFileIO. | `org.apache.iceberg.io.ResolvingFileIO` | No |
+| `gravitino.iceberg-rest.gcs-service-account-file` | Path of the GCS service
account JSON file. Used for server-side FileIO and for `gcs-token` credential
vending. | GCS Application default credential. | No |
For other Iceberg GCS properties not managed by Gravitino like
`gcs.project-id`, you could config it directly by
`gravitino.iceberg-rest.gcs.project-id`.
Refer to [GCS credentials](./security/credential-vending.md#gcs-credentials)
for credential related configurations.
:::note
-Ensure that the credential file is accessible by the Gravitino server. For
example, the server may be running on a GCE machine, or you may set the
environment variable `export
GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json` even
when `gcs-service-account-file` is already configured.
+When `gcs-service-account-file` is set, Gravitino loads it at catalog
initialization and injects Iceberg `gcs.oauth2.token` for FileIO. The IRC
catalog cache evicts that catalog before the token expires so the next request
recreates the catalog and mints a fresh token. If unset, use Application
Default Credentials (for example GCE metadata or
`GOOGLE_APPLICATION_CREDENTIALS`).
:::
:::info
diff --git a/docs/lakehouse-iceberg-catalog.md
b/docs/lakehouse-iceberg-catalog.md
index 996c0427e9..6f4741f15f 100644
--- a/docs/lakehouse-iceberg-catalog.md
+++ b/docs/lakehouse-iceberg-catalog.md
@@ -175,13 +175,14 @@ The Gravitino Iceberg aliyun bundle jar already includes
the Iceberg aliyun nece
Supports using google credential file to access GCS data.
-| Configuration item | Description
| Default
value | Required |
-|--------------------|------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|----------|
-| `io-impl` | The IO implementation for `FileIO` in Iceberg. Set it
to `org.apache.iceberg.gcp.gcs.GCSFileIO` to explicitly use GCSFileIO. |
`org.apache.iceberg.io.ResolvingFileIO` | No |
+| Configuration item | Description
|
Default value | Required |
+|----------------------------|------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|----------|
+| `io-impl` | The IO implementation for `FileIO` in Iceberg.
Set it to `org.apache.iceberg.gcp.gcs.GCSFileIO` to explicitly use GCSFileIO. |
`org.apache.iceberg.io.ResolvingFileIO` | No |
+| `gcs-service-account-file` | Path of the GCS service account JSON file. Used
for server-side FileIO and for `gcs-token` credential vending. |
GCS Application default credential. | No |
For other Iceberg GCS properties not managed by Gravitino like
`gcs.project-id`, you could config it directly by
`gravitino.bypass.gcs.project-id`.
-Please make sure the credential file is accessible by Gravitino, like using
`export
GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json` before
Gravitino server is started.
+When `gcs-service-account-file` is set, Gravitino loads it at catalog
initialization and injects Iceberg `gcs.oauth2.token` for FileIO (Iceberg's
`GCSFileIO` has no service-account-file property). If that property is unset,
fall back to Application Default Credentials, for example `export
GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json`.
:::info
Please set `warehouse` to `gs://{bucket_name}/${prefix_name}`, and download
[Gravitino Iceberg GCP bundle
jar](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-iceberg-gcp-bundle)
and place it to `catalogs/lakehouse-iceberg/libs/`.
diff --git a/docs/security/credential-vending.md
b/docs/security/credential-vending.md
index 8e0c798af3..7b1aeb9a01 100755
--- a/docs/security/credential-vending.md
+++ b/docs/security/credential-vending.md
@@ -403,7 +403,7 @@ There is no role to assume. The identity is the service
account in `gcs-service-
|----------------------------|------------------------------------------|-------------------------------------|----------|
| `gcs-service-account-file` | The location of the GCS credential file. | GCS
Application default credential. | No |
-For the IRC, ensure that the credential file is accessible by that server. For
example, the server may be running on a GCE machine, or you may set the
environment variable `export
GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json` even
when `gcs-service-account-file` is already configured.
+`gcs-service-account-file` is used both to vend downscoped tokens and to
authenticate Iceberg `GCSFileIO` on the server (Gravitino injects
`gcs.oauth2.token` at catalog load because Iceberg has no service-account-file
property). Ensure the file is readable by the server process. If the property
is unset, FileIO and token vending fall back to Application Default Credentials
(for example GCE metadata or `GOOGLE_APPLICATION_CREDENTIALS`).
## Requesting Vended Credentials
diff --git a/iceberg/iceberg-common/build.gradle.kts
b/iceberg/iceberg-common/build.gradle.kts
index 938fea8e31..364e5cf29c 100644
--- a/iceberg/iceberg-common/build.gradle.kts
+++ b/iceberg/iceberg-common/build.gradle.kts
@@ -62,6 +62,8 @@ dependencies {
implementation(libs.iceberg.azure)
implementation(libs.iceberg.hive.metastore)
implementation(libs.iceberg.gcp)
+ // Load gcs-service-account-file into Iceberg GCSFileIO properties
(gcs.oauth2.token).
+ implementation(libs.google.auth.http)
// Upgrade to Hadoop 3.3+ for Iceberg 1.10 compatibility
// Iceberg 1.10 requires Hadoop 3.3+ APIs like FileSystem.openFile() and
FsTracer.get()
implementation(libs.hadoop3.client.api)
diff --git
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
index 745b6b1d4f..658786ea15 100644
---
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
+++
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
@@ -21,15 +21,25 @@ package org.apache.gravitino.iceberg.common.utils;
import static
org.apache.hadoop.fs.CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION;
import static
org.apache.hadoop.fs.CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHORIZATION;
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.GoogleCredentials;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.sql.SQLException;
import java.util.Collections;
+import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogBackend;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.exceptions.ConnectionFailedException;
@@ -38,6 +48,7 @@ import
org.apache.gravitino.iceberg.common.ClosableJdbcCatalog;
import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.common.authentication.AuthenticationConfig;
import
org.apache.gravitino.iceberg.common.rest.auth.UserPrincipalForwardingAuthManager;
+import org.apache.gravitino.storage.GCSProperties;
import org.apache.hadoop.hdfs.HdfsConfiguration;
import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.CatalogUtil;
@@ -65,6 +76,9 @@ public class IcebergCatalogUtil {
*/
private static final String ICEBERG_TYPE_COLUMN = "iceberg_type";
+ private static final String GCS_CLOUD_PLATFORM_SCOPE =
+ "https://www.googleapis.com/auth/cloud-platform";
+
private static final ConcurrentHashMap<String, InMemoryCatalog>
MEMORY_CATALOGS =
new ConcurrentHashMap<>();
@@ -269,6 +283,84 @@ public class IcebergCatalogUtil {
@VisibleForTesting
public static void applyDefaultResolvingFileIO(Map<String, String>
properties) {
properties.putIfAbsent(IcebergConstants.IO_IMPL,
ResolvingFileIO.class.getName());
+ applyGcsServiceAccountCredentials(properties);
+ }
+
+ /**
+ * When {@code gcs-service-account-file} is set, mint an OAuth2 access token
and inject Iceberg
+ * {@code gcs.oauth2.token} / {@code gcs.oauth2.token-expires-at} so the
built-in {@code
+ * GCSFileIO} can authenticate. Iceberg's FileIO does not understand
Gravitino's
+ * service-account-file property; S3/OSS/ADLS instead map static keys
directly via {@link
+ * org.apache.gravitino.catalog.lakehouse.iceberg.IcebergPropertiesUtils}.
+ *
+ * <p>Skips injection when {@code gcs.oauth2.token} is already present.
Disables Iceberg's
+ * credentials-endpoint refresh because that path is for vended table
credentials, not catalog
+ * bootstrap from a service account file.
+ *
+ * @param properties Iceberg catalog properties, mutated in place
+ */
+ @VisibleForTesting
+ static void applyGcsServiceAccountCredentials(Map<String, String>
properties) {
+ String serviceAccountFile =
properties.get(GCSProperties.GRAVITINO_GCS_SERVICE_ACCOUNT_FILE);
+ if (StringUtils.isBlank(serviceAccountFile)) {
+ return;
+ }
+ if
(StringUtils.isNotBlank(properties.get(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN)))
{
+ return;
+ }
+
+ AccessToken accessToken = loadAccessTokenFromFile(serviceAccountFile);
+ if (accessToken == null ||
StringUtils.isBlank(accessToken.getTokenValue())) {
+ throw new IllegalStateException(
+ "Failed to obtain GCS access token from service account file: " +
serviceAccountFile);
+ }
+
+ properties.put(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN,
accessToken.getTokenValue());
+ Date expirationTime = accessToken.getExpirationTime();
+ if (expirationTime != null) {
+ properties.put(
+ IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT,
+ String.valueOf(expirationTime.toInstant().toEpochMilli()));
+ }
+
properties.put(IcebergConstants.ICEBERG_GCS_OAUTH2_REFRESH_CREDENTIALS_ENABLED,
"false");
+ LOG.info(
+ "Injected {} from {} for Iceberg GCSFileIO",
+ IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN,
+ GCSProperties.GRAVITINO_GCS_SERVICE_ACCOUNT_FILE);
+ }
+
+ /**
+ * Returns an {@link IcebergConfig} that includes a minted GCS OAuth2 token
when {@code
+ * gcs-service-account-file} is configured. The returned config retains
{@code
+ * gcs.oauth2.token-expires-at} so callers (for example the IRC catalog
cache) can expire the
+ * catalog before the token becomes invalid.
+ *
+ * @param icebergConfig original catalog config
+ * @return the same instance when no token is injected; otherwise a new
config with token fields
+ */
+ public static IcebergConfig withGcsServiceAccountCredentials(IcebergConfig
icebergConfig) {
+ Map<String, String> properties = new
HashMap<>(icebergConfig.getAllConfig());
+ applyGcsServiceAccountCredentials(properties);
+ if (properties.equals(icebergConfig.getAllConfig())) {
+ return icebergConfig;
+ }
+ return new IcebergConfig(properties);
+ }
+
+ private static AccessToken loadAccessTokenFromFile(String
serviceAccountFile) {
+ Path credentialsFilePath = Paths.get(serviceAccountFile);
+ try (InputStream inputStream = Files.newInputStream(credentialsFilePath)) {
+ GoogleCredentials credentials =
+
GoogleCredentials.fromStream(inputStream).createScoped(GCS_CLOUD_PLATFORM_SCOPE);
+ credentials.refreshIfExpired();
+ return credentials.getAccessToken();
+ } catch (NoSuchFileException e) {
+ throw new UncheckedIOException(
+ "GCS service account file does not exist: " + serviceAccountFile, e);
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Failed to load GCS service account file: " + serviceAccountFile, e);
+ }
}
@VisibleForTesting
diff --git
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
index f5ee1b8cf0..d8ce5cc18a 100644
---
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
+++
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.iceberg.common.utils;
+import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.sql.SQLException;
import java.sql.SQLSyntaxErrorException;
@@ -29,6 +30,7 @@ import
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.common.ClosableJdbcCatalog;
import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.common.authentication.AuthenticationConfig;
+import org.apache.gravitino.storage.GCSProperties;
import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.Schema;
import org.apache.iceberg.catalog.Catalog;
@@ -337,6 +339,74 @@ public class TestIcebergCatalogUtil {
"org.apache.iceberg.aws.s3.S3FileIO",
properties.get(IcebergConstants.IO_IMPL));
}
+ @Test
+ void testApplyGcsServiceAccountCredentialsSkipsWhenTokenAlreadyPresent() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(GCSProperties.GRAVITINO_GCS_SERVICE_ACCOUNT_FILE,
"/tmp/gcs-key.json");
+ properties.put(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN,
"existing-token");
+
+ IcebergCatalogUtil.applyGcsServiceAccountCredentials(properties);
+
+ Assertions.assertEquals(
+ "existing-token",
properties.get(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN));
+
Assertions.assertNull(properties.get(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT));
+ }
+
+ @Test
+ void testApplyGcsServiceAccountCredentialsNoOpWithoutServiceAccountFile() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(IcebergConstants.IO_IMPL,
"org.apache.iceberg.gcp.gcs.GCSFileIO");
+
+ IcebergCatalogUtil.applyGcsServiceAccountCredentials(properties);
+
+
Assertions.assertNull(properties.get(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN));
+ }
+
+ @Test
+ void testApplyGcsServiceAccountCredentialsFailsWhenFileMissing() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(
+ GCSProperties.GRAVITINO_GCS_SERVICE_ACCOUNT_FILE,
"/tmp/gravitino-missing-gcs-key.json");
+
+ UncheckedIOException thrown =
+ Assertions.assertThrows(
+ UncheckedIOException.class,
+ () ->
IcebergCatalogUtil.applyGcsServiceAccountCredentials(properties));
+ Assertions.assertTrue(thrown.getMessage().contains("does not exist"));
+ }
+
+ @Test
+ void
testWithGcsServiceAccountCredentialsReturnsSameConfigWhenNoServiceAccountFile()
{
+ IcebergConfig config = new
IcebergConfig(Map.of(IcebergConstants.CATALOG_BACKEND, "memory"));
+ Assertions.assertSame(config,
IcebergCatalogUtil.withGcsServiceAccountCredentials(config));
+ }
+
+ @Test
+ void
testWithGcsServiceAccountCredentialsReturnsSameConfigWhenTokenAlreadyPresent() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(GCSProperties.GRAVITINO_GCS_SERVICE_ACCOUNT_FILE,
"/tmp/gcs-key.json");
+ properties.put(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN,
"existing-token");
+ IcebergConfig config = new IcebergConfig(properties);
+
+ Assertions.assertSame(config,
IcebergCatalogUtil.withGcsServiceAccountCredentials(config));
+ }
+
+ @Test
+ void testApplyDefaultResolvingFileIOInjectsGcsToken() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(IcebergConstants.WAREHOUSE, "gs://bucket/warehouse");
+ properties.put(GCSProperties.GRAVITINO_GCS_SERVICE_ACCOUNT_FILE,
"/tmp/gcs-key.json");
+
+ // Pre-set token so applyDefaultResolvingFileIO skips loading a real
service account file.
+ properties.put(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN, "pre-set");
+ IcebergCatalogUtil.applyDefaultResolvingFileIO(properties);
+
+ Assertions.assertEquals(
+ org.apache.iceberg.io.ResolvingFileIO.class.getName(),
+ properties.get(IcebergConstants.IO_IMPL));
+ Assertions.assertEquals("pre-set",
properties.get(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN));
+ }
+
@Test
void testApplyRestCatalogHttpTimeoutPropertiesUsesDefaults() {
Map<String, String> properties = new HashMap<>();
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
index 5d8fbf52f1..a960f175e0 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.iceberg.service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.Scheduler;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
@@ -28,6 +29,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogBackend;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
@@ -37,6 +39,7 @@ import
org.apache.gravitino.iceberg.common.authentication.AuthenticationConfig;
import org.apache.gravitino.iceberg.common.authentication.SupportsKerberos;
import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
import
org.apache.gravitino.iceberg.common.ops.KerberosAwareIcebergCatalogProxy;
+import org.apache.gravitino.iceberg.common.utils.IcebergCatalogUtil;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import
org.apache.gravitino.iceberg.service.provider.DynamicIcebergConfigProvider;
import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
@@ -47,6 +50,12 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
public static final Logger LOG =
LoggerFactory.getLogger(IcebergCatalogWrapperManager.class);
+ /**
+ * Evict a cached catalog this long before its minted GCS OAuth2 token
expires, so the next
+ * request recreates the catalog and refreshes the token.
+ */
+ @VisibleForTesting static final long GCS_TOKEN_REFRESH_BUFFER_MS =
TimeUnit.MINUTES.toMillis(5);
+
private final Cache<String, CatalogWrapperForREST> catalogWrapperCache;
private final IcebergConfigProvider configProvider;
@@ -57,20 +66,20 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
boolean auxMode,
String metalakeName) {
this.configProvider = configProvider;
+ long accessEvictionNanos =
+ TimeUnit.MILLISECONDS.toNanos(
+ new IcebergConfig(properties)
+
.get(IcebergConfig.ICEBERG_REST_CATALOG_CACHE_EVICTION_INTERVAL));
this.catalogWrapperCache =
Caffeine.newBuilder()
- .expireAfterAccess(
- (new IcebergConfig(properties))
-
.get(IcebergConfig.ICEBERG_REST_CATALOG_CACHE_EVICTION_INTERVAL),
- TimeUnit.MILLISECONDS)
+ .expireAfter(new CatalogWrapperExpiry(accessEvictionNanos))
.removalListener(
- (k, v, c) -> {
- String catalogName = (String) k;
+ (catalogName, catalogWrapper, cause) -> {
LOG.debug(
"Removing IcebergCatalogWrapper from cache: catalog={},
cause={}",
catalogName,
- c);
- closeIcebergCatalogWrapper((IcebergCatalogWrapper) v);
+ cause);
+ closeIcebergCatalogWrapper(catalogWrapper);
})
.scheduler(
Scheduler.forScheduledExecutorService(
@@ -140,22 +149,26 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
@VisibleForTesting
protected CatalogWrapperForREST createCatalogWrapper(
String catalogName, IcebergConfig icebergConfig) {
+ // Mint GCS OAuth2 tokens into the config before constructing the wrapper
so the IRC catalog
+ // cache can expire the entry before gcs.oauth2.token-expires-at.
+ IcebergConfig enrichedConfig =
+ IcebergCatalogUtil.withGcsServiceAccountCredentials(icebergConfig);
// When the backend is a federated Iceberg REST catalog, use
FederatedCatalogWrapper so
// federation-aware behavior (FileIO property extraction, remote
credential vending, remote
// /v1/config defaults) is applied through polymorphic dispatch rather
than scattered
// instanceof checks. All other backends use the base
CatalogWrapperForREST.
IcebergCatalogBackend backend =
IcebergCatalogBackend.valueOf(
-
icebergConfig.get(IcebergConfig.CATALOG_BACKEND).toUpperCase(Locale.ROOT));
+
enrichedConfig.get(IcebergConfig.CATALOG_BACKEND).toUpperCase(Locale.ROOT));
CatalogWrapperForREST rest =
backend == IcebergCatalogBackend.REST
- ? new FederatedCatalogWrapper(catalogName, icebergConfig)
- : new CatalogWrapperForREST(catalogName, icebergConfig);
+ ? new FederatedCatalogWrapper(catalogName, enrichedConfig)
+ : new CatalogWrapperForREST(catalogName, enrichedConfig);
AuthenticationConfig authenticationConfig =
- new AuthenticationConfig(icebergConfig.getAllConfig());
+ new AuthenticationConfig(enrichedConfig.getAllConfig());
if (authenticationConfig.isKerberosAuth() && rest.getCatalog() instanceof
SupportsKerberos) {
return (CatalogWrapperForREST)
- new KerberosAwareIcebergCatalogProxy(rest).getProxy(catalogName,
icebergConfig);
+ new KerberosAwareIcebergCatalogProxy(rest).getProxy(catalogName,
enrichedConfig);
}
return rest;
@@ -169,8 +182,73 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
}
}
+ /**
+ * Computes how long a catalog wrapper may stay in the IRC cache.
+ *
+ * <p>Uses the configured access-based eviction interval, capped by the time
until a minted GCS
+ * OAuth2 token should be refreshed ({@code gcs.oauth2.token-expires-at}
minus {@link
+ * #GCS_TOKEN_REFRESH_BUFFER_MS}). When no token expiry is present, returns
{@code
+ * accessEvictionNanos}.
+ *
+ * @param config catalog config that may contain {@code
gcs.oauth2.token-expires-at}
+ * @param accessEvictionNanos default expire-after-access duration in
nanoseconds
+ * @param nowEpochMillis current wall-clock time
+ * @return cache duration in nanoseconds; {@code 0} means expire immediately
+ */
+ @VisibleForTesting
+ static long computeCacheDurationNanos(
+ IcebergConfig config, long accessEvictionNanos, long nowEpochMillis) {
+ String expiresAt =
+
config.getAllConfig().get(IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT);
+ if (StringUtils.isBlank(expiresAt)) {
+ return accessEvictionNanos;
+ }
+
+ long expiresAtMs;
+ try {
+ expiresAtMs = Long.parseLong(expiresAt);
+ } catch (NumberFormatException e) {
+ LOG.warn("Invalid {}: {}",
IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT, expiresAt);
+ return accessEvictionNanos;
+ }
+
+ long remainingMs = expiresAtMs - GCS_TOKEN_REFRESH_BUFFER_MS -
nowEpochMillis;
+ if (remainingMs <= 0) {
+ return 0L;
+ }
+ return Math.min(accessEvictionNanos,
TimeUnit.MILLISECONDS.toNanos(remainingMs));
+ }
+
@Override
public void close() throws Exception {
catalogWrapperCache.invalidateAll();
}
+
+ private static final class CatalogWrapperExpiry implements Expiry<String,
CatalogWrapperForREST> {
+
+ private final long accessEvictionNanos;
+
+ CatalogWrapperExpiry(long accessEvictionNanos) {
+ this.accessEvictionNanos = accessEvictionNanos;
+ }
+
+ @Override
+ public long expireAfterCreate(String key, CatalogWrapperForREST value,
long currentTime) {
+ return computeCacheDurationNanos(
+ value.getIcebergConfig(), accessEvictionNanos,
System.currentTimeMillis());
+ }
+
+ @Override
+ public long expireAfterUpdate(
+ String key, CatalogWrapperForREST value, long currentTime, long
currentDuration) {
+ return expireAfterCreate(key, value, currentTime);
+ }
+
+ @Override
+ public long expireAfterRead(
+ String key, CatalogWrapperForREST value, long currentTime, long
currentDuration) {
+ // Preserve expire-after-access, but never extend past the GCS token
refresh deadline.
+ return expireAfterCreate(key, value, currentTime);
+ }
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
index 45eb62f69e..3a5e8925e1 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.apache.commons.lang3.StringUtils;
@@ -189,6 +190,70 @@ public class TestIcebergCatalogWrapperManagerForREST {
}
}
+ @Test
+ public void
testComputeCacheDurationNanosWithoutTokenExpiryUsesAccessEviction() {
+ IcebergConfig config =
+ new IcebergConfig(ImmutableMap.of(IcebergConstants.CATALOG_BACKEND,
"memory"));
+ long accessEvictionNanos = TimeUnit.HOURS.toNanos(1);
+ Assertions.assertEquals(
+ accessEvictionNanos,
+ IcebergCatalogWrapperManager.computeCacheDurationNanos(
+ config, accessEvictionNanos, System.currentTimeMillis()));
+ }
+
+ @Test
+ public void testComputeCacheDurationNanosCapsByGcsTokenExpiry() {
+ long now = 1_700_000_000_000L;
+ long expiresAt = now + TimeUnit.HOURS.toMillis(1); // token valid for 1h
+ IcebergConfig config =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT,
+ String.valueOf(expiresAt)));
+ long accessEvictionNanos = TimeUnit.HOURS.toNanos(2);
+ long expected =
+ TimeUnit.MILLISECONDS.toNanos(
+ TimeUnit.HOURS.toMillis(1) -
IcebergCatalogWrapperManager.GCS_TOKEN_REFRESH_BUFFER_MS);
+ Assertions.assertEquals(
+ expected,
+ IcebergCatalogWrapperManager.computeCacheDurationNanos(config,
accessEvictionNanos, now));
+ }
+
+ @Test
+ public void
testComputeCacheDurationNanosExpiresImmediatelyWhenPastRefreshDeadline() {
+ long now = 1_700_000_000_000L;
+ long expiresAt = now + TimeUnit.MINUTES.toMillis(2); // within 5-minute
buffer
+ IcebergConfig config =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT,
+ String.valueOf(expiresAt)));
+ Assertions.assertEquals(
+ 0L,
+ IcebergCatalogWrapperManager.computeCacheDurationNanos(
+ config, TimeUnit.HOURS.toNanos(1), now));
+ }
+
+ @Test
+ public void testComputeCacheDurationNanosIgnoresInvalidExpiresAt() {
+ IcebergConfig config =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ IcebergConstants.ICEBERG_GCS_OAUTH2_TOKEN_EXPIRES_AT,
+ "not-a-number"));
+ long accessEvictionNanos = TimeUnit.MINUTES.toNanos(30);
+ Assertions.assertEquals(
+ accessEvictionNanos,
+ IcebergCatalogWrapperManager.computeCacheDurationNanos(
+ config, accessEvictionNanos, System.currentTimeMillis()));
+ }
+
private static IcebergCatalogWrapperManager newManager() {
Map<String, String> config = Maps.newHashMap();
IcebergConfigProvider configProvider =
IcebergConfigProviderFactory.create(config);