This is an automated email from the ASF dual-hosted git repository.
diqiu50 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 0b99555a41 [Cherry-pick to branch-1.3] [#12709]
improvement(spark-connector): Route lakehouse-iceberg catalogs through the
Iceberg REST server (#12710) (#12864)
0b99555a41 is described below
commit 0b99555a410f11966528446e193bb49d4945a37c
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Sep 4 17:22:52 2026 +0800
[Cherry-pick to branch-1.3] [#12709] improvement(spark-connector): Route
lakehouse-iceberg catalogs through the Iceberg REST server (#12710) (#12864)
**Cherry-pick Information:**
- Original commit: 5b666b322b0da5f2ded3e0f23591c3e8f71e0ea8
- Target branch: `branch-1.3`
- Status: ⚠️ **Has conflicts - manual resolution required**
---------
Co-authored-by: Yuhui <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: diqiu50 <[email protected]>
---
.../workflows/spark-integration-test-action.yml | 2 +-
docs/spark-connector/spark-catalog-iceberg.md | 99 ++++++++-
docs/spark-connector/spark-connector.md | 20 +-
.../spark/connector/GravitinoSparkConfig.java | 24 ++
.../connector/catalog/GravitinoCatalogManager.java | 32 ++-
.../connector/iceberg/GravitinoIcebergCatalog.java | 154 ++++++++++++-
.../iceberg/IcebergPropertiesConstants.java | 18 ++
.../iceberg/IcebergPropertiesConverter.java | 205 +++++++++++++++++
.../connector/iceberg/IcebergRestOAuthConfig.java | 135 +++++++++++
.../connector/plugin/GravitinoDriverPlugin.java | 1 +
.../catalog/TestGravitinoCatalogManager.java | 107 +++++++++
.../connector/glue/TestGravitinoGlueCatalog.java | 2 +-
.../TestGravitinoIcebergCatalogRestRouting.java | 246 +++++++++++++++++++++
.../iceberg/TestIcebergPropertiesConverter.java | 190 ++++++++++++++++
.../iceberg/TestIcebergRestOAuthConfig.java | 205 +++++++++++++++++
.../connector/integration/test/SparkEnvIT.java | 71 ++++--
.../iceberg/SparkIcebergCatalogHiveBackendIT.java | 10 +
.../iceberg/SparkIcebergCatalogRestRoutingIT.java | 76 +++++++
.../SparkIcebergCatalogRestRoutingIT33.java | 62 ++++++
.../SparkIcebergCatalogRestRoutingIT34.java | 25 +++
spark-connector/v3.5/spark/build.gradle.kts | 2 +
.../SparkIcebergCatalogRestRoutingIT35.java | 25 +++
...kIcebergCatalogRestS3CredentialVendingIT35.java | 239 ++++++++++++++++++++
23 files changed, 1914 insertions(+), 36 deletions(-)
diff --git a/.github/workflows/spark-integration-test-action.yml
b/.github/workflows/spark-integration-test-action.yml
index c0f7a1e712..39d679fda9 100644
--- a/.github/workflows/spark-integration-test-action.yml
+++ b/.github/workflows/spark-integration-test-action.yml
@@ -25,7 +25,7 @@ jobs:
start-runner:
name: JDK${{ inputs.java-version }}-${{ inputs.test-mode }}-Scala${{
inputs.scala-version }}
runs-on: ubuntu-latest
- timeout-minutes: 60
+ timeout-minutes: 120
env:
PLATFORM: ${{ inputs.architecture }}
steps:
diff --git a/docs/spark-connector/spark-catalog-iceberg.md
b/docs/spark-connector/spark-catalog-iceberg.md
index fd3210e4eb..056c70dbd4 100644
--- a/docs/spark-connector/spark-catalog-iceberg.md
+++ b/docs/spark-connector/spark-catalog-iceberg.md
@@ -148,13 +148,110 @@ Gravitino catalog property names with the prefix
`spark.bypass.` are passed to S
Iceberg catalog property `cache-enabled` is setting to `false` internally and
not allowed to change.
:::
+## Routing Through the Gravitino Iceberg REST Server
+
+If the Gravitino server exposes an [Iceberg REST
catalog](../iceberg-rest-service.md) (IRC) endpoint for the
+current metalake, the Spark connector automatically routes `hive` and `jdbc`
backed Iceberg catalogs through
+that endpoint instead of talking to the Hive metastore or JDBC database
directly. This has no effect on
+catalogs whose `catalog-backend` is already `rest` or `custom`.
+
+Routing through the IRC server is the only way to receive short-lived,
per-table **vended credentials**
+that Iceberg's native REST protocol refreshes automatically. The non-REST path
can still inject a single
+vended credential fetched once at catalog initialization (see
+[Credential vending](../security/credential-vending.md)), but it is not
refreshed per table access.
+
+REST routing is enabled by default. The endpoint is discovered once per Spark
application — for the life
+of the Gravitino Spark plugin, not per `SparkSession` — and is not re-checked
afterward.
+
+- If no discoverable endpoint is found (for example, the `iceberg-rest`
auxiliary service is disabled or
+ not configured with `catalog-config-provider=dynamic-config-provider`),
catalog initialization fails
+ with an actionable error. Set
`spark.sql.gravitino.iceberg.rest-routing-enabled=false` to retain the
+ native Hive/JDBC backend instead.
+- A catalog whose warehouse uses a scheme with a native Iceberg FileIO
(`s3://`, `gs://`, `abfs://`, etc.)
+ must have [credential vending](../security/credential-vending.md) configured
(`credential-providers`)
+ before it can be routed: routing replaces any static storage credentials for
that FileIO with vended
+ ones, and without credential vending the catalog would lose storage access.
Catalog initialization
+ fails with an actionable error if this is not configured. Set
`rest-routing-enabled=false` for that
+ catalog to keep using the legacy Hive/JDBC backend instead.
+
+To force a specific endpoint instead of relying on auto-discovery, set:
+
+```properties
+spark.sql.gravitino.iceberg.rest-uri http://<gravitino-host>:9001/iceberg
+```
+
+To retain the legacy Hive/JDBC translation and skip endpoint discovery,
disable routing explicitly:
+
+```properties
+spark.sql.gravitino.iceberg.rest-routing-enabled false
+```
+
+If Gravitino requires authentication on the IRC endpoint, pass the Iceberg
REST client's own auth
+properties using the `spark.sql.gravitino.iceberg.rest.` prefix. For example,
for Basic authentication:
+
+```properties
+spark.sql.gravitino.iceberg.rest.rest.auth.type basic
+spark.sql.gravitino.iceberg.rest.rest.auth.basic.username <username>
+spark.sql.gravitino.iceberg.rest.rest.auth.basic.password <password>
+```
+
+See [Connect Spark to Iceberg REST](../iceberg-rest-engine/spark.md) for the
full set of supported
+`rest.auth.*` properties and how to configure them when connecting directly to
the IRC endpoint.
+
+When the Gravitino client uses OAuth2, the connector reuses its OAuth2 client
configuration for IRC by
+default. This avoids duplicating configuration when both endpoints accept the
same client identity. The
+equivalent explicit setting is:
+
+```properties
+spark.sql.gravitino.iceberg.reuseOAuth2 true
+```
+
+Gravitino and IRC may use different OAuth2 clients even when IRC runs as a
Gravitino auxiliary service.
+Override any reused value with an IRC-specific Iceberg REST property;
unspecified values continue to come
+from the Gravitino client configuration:
+
+```properties
+# Gravitino metadata API client
+spark.sql.gravitino.authType oauth2
+spark.sql.gravitino.oauth2.serverUri https://identity.example.com
+spark.sql.gravitino.oauth2.tokenPath /oauth/token
+spark.sql.gravitino.oauth2.credential
<gravitino-client-id>:<gravitino-client-secret>
+spark.sql.gravitino.oauth2.scope gravitino
+
+# IRC data-plane client override
+spark.sql.gravitino.iceberg.rest.credential
<irc-client-id>:<irc-client-secret>
+spark.sql.gravitino.iceberg.rest.scope iceberg
+```
+
+The IRC properties take precedence field by field. This validation also
applies when reusing the Gravitino
+configuration by default with no IRC-specific override at all: if the reused
configuration itself is
+incomplete, catalog initialization fails and identifies the missing
properties. Set
+`spark.sql.gravitino.iceberg.reuseOAuth2=false` when supplying a complete,
independent IRC authentication
+configuration or when IRC does not require OAuth2.
+
+:::caution
+Spark's UI redacts environment values whose property name matches
`secret|password|token`, which does not
+match `credential`. Both `spark.sql.gravitino.oauth2.credential` and
`spark.sql.gravitino.iceberg.rest.credential`
+are shown in plain text on the Spark UI's environment page; set
`spark.redaction.regex` to also match
+`credential` if this is a concern.
+:::
+
+Because vended credentials are only consumed by Iceberg's native `FileIO`
implementations, make sure the
+warehouse storage jars listed under [Storage](#storage) below are on the Spark
classpath; the connector
+derives `io-impl` automatically from the warehouse location's scheme
(`s3`/`s3a`/`s3n`, `gs`,
+`abfs`/`abfss`/`wasb`/`wasbs`, `oss`) unless `io-impl` is already set
explicitly on the catalog.
+
## Storage
Spark connector could convert storage properties in the Gravitino catalog to
Spark Iceberg connector automatically, No extra configuration is needed for
`S3`, `ADLS`, `OSS`, `GCS`.
### S3
-Please downloading the [Iceberg AWS
bundle](https://mvnrepository.com/artifact/org.apache.iceberg/iceberg-aws-bundle)
and place it in the classpath of Spark.
+Download the [Iceberg AWS
bundle](https://mvnrepository.com/artifact/org.apache.iceberg/iceberg-aws-bundle)
+that matches the Iceberg runtime version and place it on the Spark driver and
executor classpaths. This is
+required for `S3FileIO` even when the Spark image already includes the AWS SDK
v1 used by Hadoop S3A;
+`S3FileIO` uses AWS SDK v2 from `iceberg-aws-bundle`. If the bundle is absent,
initialization can fail with
+a `NoClassDefFoundError` that does not identify the missing bundle directly.
### OSS
diff --git a/docs/spark-connector/spark-connector.md
b/docs/spark-connector/spark-connector.md
index 5a3d8562c1..36bd7e384e 100644
--- a/docs/spark-connector/spark-connector.md
+++ b/docs/spark-connector/spark-connector.md
@@ -26,14 +26,18 @@ The Apache Gravitino Spark connector leverages the Spark
DataSourceV2 interface
1. [Build](../how-to-build.md) or download the package
([gravitino-spark-connector-runtime-3.3](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-spark-connector-runtime-3.3),
[gravitino-spark-connector-runtime-3.4](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-spark-connector-runtime-3.4),
[gravitino-spark-connector-runtime-3.5](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-spark-connector-runtime-3.5)),
and place it to the classpa [...]
2. Configure the Spark session to use the Gravitino spark connector.
-| Property | Type | Default Value |
Description
| Required |
-|------------------------------------------|--------|---------------|-------------------------------------------------------------------------------------------------|----------|
-| spark.plugins | string | (none) |
Gravitino spark plugin name,
`org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin` | Yes |
-| spark.sql.gravitino.metalake | string | (none) | The
metalake name that spark connector used to request to Gravitino.
| Yes |
-| spark.sql.gravitino.uri | string | (none) | The uri
of Gravitino server address.
| Yes |
-| spark.sql.gravitino.enableIcebergSupport | string | `false` | Set to
`true` to use Iceberg catalog.
| No |
-| spark.sql.gravitino.enablePaimonSupport | string | `false` | Set to
`true` to use Paimon catalog.
| No |
-| spark.sql.gravitino.client. | string | (none) | The
configuration key prefix for the Gravitino client config.
| No |
+| Property | Type | Default Value |
Description
| Required |
+|---------------------------------------------------|---------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
+| spark.plugins | string | (none) |
Gravitino spark plugin name,
`org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin`
| Yes |
+| spark.sql.gravitino.metalake | string | (none) |
The metalake name that spark connector used to request to Gravitino.
| Yes |
+| spark.sql.gravitino.uri | string | (none) |
The uri of Gravitino server address.
| Yes |
+| spark.sql.gravitino.enableIcebergSupport | string | `false` |
Set to `true` to use Iceberg catalog.
| No |
+| spark.sql.gravitino.enablePaimonSupport | string | `false` |
Set to `true` to use Paimon catalog.
| No |
+| spark.sql.gravitino.client. | string | (none) |
The configuration key prefix for the Gravitino client config.
| No |
+| spark.sql.gravitino.iceberg.rest-routing-enabled | boolean | `true` |
Whether `hive` and `jdbc` backed Iceberg catalogs must be routed through the
Gravitino Iceberg REST server. Set to `false` to retain legacy native backend
translation. | No |
+| spark.sql.gravitino.iceberg.rest-uri | string | (none) |
Overrides the auto-discovered Gravitino Iceberg REST server endpoint. See
[Iceberg
catalog](spark-catalog-iceberg.md#routing-through-the-gravitino-iceberg-rest-server).
| No |
+| spark.sql.gravitino.iceberg.reuseOAuth2 | boolean | `true` |
Reuses the Gravitino OAuth2 client configuration for routed Iceberg REST
catalogs. Explicit IRC OAuth2 properties override individual reused values.
| No |
+| spark.sql.gravitino.iceberg.rest. | string | (none) |
The configuration key prefix for the Iceberg REST client config (e.g.
`rest.auth.type`), applied when a catalog is routed through the Gravitino
Iceberg REST server. | No |
To configure the Gravitino client, use properties prefixed with
`spark.sql.gravitino.client.`. These properties will be passed to the Gravitino
client after removing the `spark.sql.` prefix.
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
index be108c9af1..e67c122176 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
@@ -32,6 +32,30 @@ public class GravitinoSparkConfig {
GRAVITINO_PREFIX + "enablePaimonSupport";
public static final String GRAVITINO_CLIENT_CONFIG_PREFIX = GRAVITINO_PREFIX
+ "client.";
+ /**
+ * Manually overrides the Gravitino Iceberg REST server endpoint that
hive/jdbc backed
+ * lakehouse-iceberg catalogs are routed through; takes precedence over
auto-discovery.
+ */
+ public static final String GRAVITINO_ICEBERG_REST_URI = GRAVITINO_PREFIX +
"iceberg.rest-uri";
+
+ /** Whether hive/jdbc backed Iceberg catalogs are routed through Iceberg
REST. */
+ public static final String GRAVITINO_ICEBERG_REST_ROUTING_ENABLED =
+ GRAVITINO_PREFIX + "iceberg.rest-routing-enabled";
+
+ /**
+ * Reuses the Gravitino OAuth2 client configuration for automatically routed
Iceberg REST
+ * catalogs. Iceberg obtains and refreshes its own access token with the
same client identity.
+ */
+ public static final String GRAVITINO_ICEBERG_REUSE_OAUTH2 =
+ GRAVITINO_PREFIX + "iceberg.reuseOAuth2";
+
+ /**
+ * Pass-through prefix for the Iceberg REST client config (e.g.
rest.auth.type,
+ * rest.auth.basic.username), applied when a catalog is routed through the
Iceberg REST server.
+ */
+ public static final String GRAVITINO_ICEBERG_REST_CONFIG_PREFIX =
+ GRAVITINO_PREFIX + "iceberg.rest.";
+
public static final String GRAVITINO_AUTH_TYPE =
GRAVITINO_PREFIX + AuthProperties.GRAVITINO_CLIENT_AUTH_TYPE;
public static final String GRAVITINO_OAUTH2_URI =
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
index a484972656..9c5a4569bf 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
@@ -24,6 +24,7 @@ import com.google.common.base.Preconditions;
import com.google.common.base.Supplier;
import java.util.Arrays;
import java.util.Map;
+import java.util.Optional;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.client.GravitinoClient;
import org.slf4j.Logger;
@@ -35,19 +36,26 @@ public class GravitinoCatalogManager {
private static GravitinoCatalogManager gravitinoCatalogManager;
private volatile boolean isClosed = false;
+ private final String metalakeName;
private final Cache<String, Catalog> gravitinoCatalogs;
private final GravitinoClient gravitinoClient;
- private GravitinoCatalogManager(Supplier<GravitinoClient> clientBuilder) {
+ // Resolved lazily on first access and cached for the life of this manager;
Spark catalogs are
+ // initialized once, so there is no refresh path if the Iceberg REST
endpoint changes later.
+ private volatile Optional<String> icebergRestUri;
+
+ private GravitinoCatalogManager(String metalakeName,
Supplier<GravitinoClient> clientBuilder) {
+ this.metalakeName = metalakeName;
this.gravitinoClient = clientBuilder.get();
// Will not evict catalog by default
this.gravitinoCatalogs = Caffeine.newBuilder().build();
}
- public static GravitinoCatalogManager create(Supplier<GravitinoClient>
clientBuilder) {
+ public static GravitinoCatalogManager create(
+ String metalakeName, Supplier<GravitinoClient> clientBuilder) {
Preconditions.checkState(
gravitinoCatalogManager == null, "Should not create duplicate
GravitinoCatalogManager");
- gravitinoCatalogManager = new GravitinoCatalogManager(clientBuilder);
+ gravitinoCatalogManager = new GravitinoCatalogManager(metalakeName,
clientBuilder);
return gravitinoCatalogManager;
}
@@ -86,6 +94,24 @@ public class GravitinoCatalogManager {
return gravitinoCatalogs.asMap();
}
+ /**
+ * Resolves the Gravitino Iceberg REST server endpoint for this manager's
metalake, if the server
+ * exposes one. The lookup is performed once and a successful response,
including a response with
+ * no endpoint, is cached for the lifetime of this manager. Request failures
are propagated.
+ *
+ * @return the discovered Iceberg REST endpoint, or empty if none is
available
+ */
+ public Optional<String> getIcebergRestUri() {
+ if (icebergRestUri == null) {
+ synchronized (this) {
+ if (icebergRestUri == null) {
+ icebergRestUri = gravitinoClient.icebergRestServiceUri(metalakeName);
+ }
+ }
+ }
+ return icebergRestUri;
+ }
+
private Catalog loadCatalog(String catalogName) {
Catalog catalog = gravitinoClient.loadCatalog(catalogName);
Preconditions.checkArgument(
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/GravitinoIcebergCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/GravitinoIcebergCatalog.java
index 0fa99d3ab7..b97cdeeef4 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/GravitinoIcebergCatalog.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/GravitinoIcebergCatalog.java
@@ -21,21 +21,33 @@ package org.apache.gravitino.spark.connector.iceberg;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Locale;
import java.util.Map;
+import java.util.Optional;
+import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergPropertiesUtils;
+import org.apache.gravitino.credential.CredentialConstants;
import org.apache.gravitino.credential.CredentialPropertyUtils;
import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
import org.apache.gravitino.spark.connector.PropertiesConverter;
import org.apache.gravitino.spark.connector.SparkTransformConverter;
import org.apache.gravitino.spark.connector.SparkTypeConverter;
import org.apache.gravitino.spark.connector.catalog.BaseCatalog;
+import org.apache.gravitino.spark.connector.catalog.GravitinoCatalogManager;
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.spark.SparkCatalog;
import org.apache.iceberg.spark.procedures.SparkProcedures;
import org.apache.iceberg.spark.source.HasIcebergCatalog;
import org.apache.iceberg.spark.source.SparkTable;
+import org.apache.spark.SparkConf;
+import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException;
import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
import org.apache.spark.sql.catalyst.analysis.NoSuchProcedureException;
@@ -72,15 +84,149 @@ public class GravitinoIcebergCatalog extends BaseCatalog
}
}
String catalogBackendName =
IcebergPropertiesUtils.getCatalogBackendName(properties);
- Map<String, String> all =
- getPropertiesConverter().toSparkCatalogProperties(options, properties);
- CredentialPropertyUtils.applyIcebergCredentials(
- CredentialPropertyUtils.getCredentials(gravitinoCatalogClient), all);
+ SparkConf sparkConf = SparkSession.active().sparkContext().conf();
+ Optional<String> icebergRestUri =
+ resolveIcebergRestUri(
+ properties,
+ key -> sparkConf.get(key, null),
+ () -> GravitinoCatalogManager.get().getIcebergRestUri());
+ Map<String, String> all;
+ if (icebergRestUri.isPresent()) {
+ all =
+ buildAutoRoutedIcebergRestProperties(
+ name, options, properties, icebergRestUri.get(), sparkConf);
+ } else {
+ all = getPropertiesConverter().toSparkCatalogProperties(options,
properties);
+ CredentialPropertyUtils.applyIcebergCredentials(
+ CredentialPropertyUtils.getCredentials(gravitinoCatalogClient), all);
+ }
TableCatalog icebergCatalog = new SparkCatalog();
icebergCatalog.initialize(catalogBackendName, new
CaseInsensitiveStringMap(all));
return icebergCatalog;
}
+ /**
+ * Resolves the Iceberg REST server endpoint to route this catalog through,
if any. Only hive/jdbc
+ * backed catalogs are eligible; a catalog already configured with {@code
catalog-backend=rest} or
+ * {@code custom} is left untouched, and so is a catalog with no {@code
catalog-backend} property
+ * at all.
+ *
+ * <p>An eligible catalog whose warehouse has a native Iceberg FileIO
(s3/gs/abfs-family schemes)
+ * fails immediately unless {@code credential-providers} is configured:
routing replaces any
+ * static storage credentials for that FileIO with vended ones, so such a
catalog would silently
+ * lose storage access once routed. A warehouse scheme with no native FileIO
(e.g. {@code
+ * hdfs://}, {@code file://}) carries no such risk and is unaffected.
+ */
+ static Optional<String> resolveIcebergRestUri(
+ Map<String, String> properties,
+ UnaryOperator<String> sessionConfig,
+ Supplier<Optional<String>> endpointDiscovery) {
+ String backend = properties.get(IcebergConstants.CATALOG_BACKEND);
+ if (backend == null) {
+ return Optional.empty();
+ }
+ String normalizedBackend = backend.toLowerCase(Locale.ROOT);
+ boolean eligible =
+
IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE.equals(normalizedBackend)
+ ||
IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_JDBC.equals(
+ normalizedBackend);
+ if (!eligible) {
+ return Optional.empty();
+ }
+
+ String routingEnabled =
+
sessionConfig.apply(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED);
+ if (StringUtils.isNotBlank(routingEnabled)
+ && !"true".equalsIgnoreCase(routingEnabled)
+ && !"false".equalsIgnoreCase(routingEnabled)) {
+ throw new IllegalArgumentException(
+ GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED
+ + " must be true or false, but was: "
+ + routingEnabled);
+ }
+ if ("false".equalsIgnoreCase(routingEnabled)) {
+ return Optional.empty();
+ }
+
+ boolean warehouseHasNativeFileIo =
+
IcebergPropertiesConverter.deriveFileIoImpl(properties.get(IcebergConstants.WAREHOUSE))
+ != null;
+ if (warehouseHasNativeFileIo
+ &&
StringUtils.isBlank(properties.get(CredentialConstants.CREDENTIAL_PROVIDERS))) {
+ throw new IllegalStateException(
+ "Catalog's warehouse has a native Iceberg FileIO but no
credential-providers "
+ + "configured; routing through the Iceberg REST server replaces
any static storage "
+ + "credentials for that FileIO with vended ones, so this catalog
would lose storage "
+ + "access once routed. Configure credential-providers on the
catalog, or set "
+ + GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED
+ + "=false to use legacy Hive/JDBC backend translation.");
+ }
+
+ String manualUri =
sessionConfig.apply(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_URI);
+ if (StringUtils.isNotBlank(manualUri)) {
+ return Optional.of(manualUri);
+ }
+
+ Optional<String> discoveredUri;
+ try {
+ discoveredUri = endpointDiscovery.get();
+ } catch (RuntimeException e) {
+ // endpointDiscovery can throw for reasons other than connectivity (e.g.
a bug in client
+ // bootstrapping), so the exception's own type is included rather than
assuming it is
+ // always a reachability/config problem.
+ throw new IllegalStateException(
+ "Failed to discover the Iceberg REST endpoint ("
+ + e.getClass().getSimpleName()
+ + "). Configure "
+ + GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_URI
+ + ", use a Gravitino server that supports
/api/system/iceberg-rest, or set "
+ + GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED
+ + "=false to use legacy Hive/JDBC backend translation.",
+ e);
+ }
+ if (!discoveredUri.isPresent()) {
+ throw new IllegalStateException(
+ "No Iceberg REST endpoint is available. Configure "
+ + GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_URI
+ + ", use a Gravitino server that supports
/api/system/iceberg-rest, or set "
+ + GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED
+ + "=false to use legacy Hive/JDBC backend translation.");
+ }
+ return discoveredUri;
+ }
+
+ private Map<String, String> buildAutoRoutedIcebergRestProperties(
+ String gravitinoCatalogName,
+ CaseInsensitiveStringMap options,
+ Map<String, String> properties,
+ String restUri,
+ SparkConf sparkConf) {
+ IcebergPropertiesConverter converter = (IcebergPropertiesConverter)
getPropertiesConverter();
+ Map<String, String> all =
+ new HashMap<>(
+ converter.buildIcebergRestProperties(
+ gravitinoCatalogName,
+ restUri,
+ properties,
+ getAutoRoutedIcebergRestClientConfig(sparkConf)));
+ if (options != null) {
+ all.putAll(options);
+ // options can re-introduce a reserved routing key (e.g. a Spark-level
`uri`/`prefix`
+ // catalog option); re-derive them so options can never redirect a
routed catalog.
+ converter.reapplyReservedRestProperties(gravitinoCatalogName, restUri,
all);
+ }
+ return all;
+ }
+
+ static Map<String, String> getAutoRoutedIcebergRestClientConfig(SparkConf
sparkConf) {
+ Map<String, String> explicitRestConfig =
+ Stream.of(
+ sparkConf.getAllWithPrefix(
+ GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_CONFIG_PREFIX))
+ .collect(Collectors.toMap(t -> t._1, t -> t._2, (oldVal, newVal)
-> newVal));
+ return IcebergRestOAuthConfig.resolve(sparkConf, explicitRestConfig);
+ }
+
@Override
protected org.apache.spark.sql.connector.catalog.Table createSparkTable(
Identifier identifier,
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConstants.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConstants.java
index 3d6ea418b4..dbd6292951 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConstants.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConstants.java
@@ -81,5 +81,23 @@ public class IcebergPropertiesConstants {
static final String GRAVITINO_ICEBERG_CATALOG_BACKEND_NAME =
IcebergConstants.CATALOG_BACKEND_NAME;
+ // Only used when routing a hive/jdbc backed catalog through the Gravitino
Iceberg REST server;
+ // not part of the Gravitino <-> Iceberg property mapping in
IcebergPropertiesUtils.
+ static final String ICEBERG_REST_CATALOG_PREFIX = "prefix";
+
+ static final String ICEBERG_IO_IMPL = IcebergConstants.IO_IMPL;
+ static final String ICEBERG_S3_ENDPOINT =
IcebergConstants.ICEBERG_S3_ENDPOINT;
+ static final String ICEBERG_S3_PATH_STYLE_ACCESS =
IcebergConstants.ICEBERG_S3_PATH_STYLE_ACCESS;
+ static final String ICEBERG_AWS_S3_REGION = IcebergConstants.AWS_S3_REGION;
+ static final String ICEBERG_OSS_ENDPOINT =
IcebergConstants.ICEBERG_OSS_ENDPOINT;
+
+ static final String ICEBERG_S3_FILE_IO_IMPL =
"org.apache.iceberg.aws.s3.S3FileIO";
+ static final String ICEBERG_GCS_FILE_IO_IMPL =
"org.apache.iceberg.gcp.gcs.GCSFileIO";
+ static final String ICEBERG_ADLS_FILE_IO_IMPL =
"org.apache.iceberg.azure.adlsv2.ADLSFileIO";
+ static final String ICEBERG_OSS_FILE_IO_IMPL =
"org.apache.iceberg.aliyun.oss.OSSFileIO";
+
+ static final String ICEBERG_ACCESS_DELEGATION =
IcebergConstants.ICEBERG_ACCESS_DELEGATION;
+ static final String ICEBERG_ACCESS_DELEGATION_VENDED_CREDENTIALS =
"vended-credentials";
+
private IcebergPropertiesConstants() {}
}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConverter.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConverter.java
index 9f7011aed5..f8fa15eb3a 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConverter.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergPropertiesConverter.java
@@ -20,17 +20,34 @@
package org.apache.gravitino.spark.connector.iceberg;
import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
import java.util.Map;
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.catalog.lakehouse.iceberg.IcebergPropertiesUtils;
+import org.apache.gravitino.credential.CredentialConstants;
import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/** Transform Apache Iceberg catalog properties between Apache Spark and
Apache Gravitino. */
public class IcebergPropertiesConverter implements PropertiesConverter {
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergPropertiesConverter.class);
+
+ /** Routing keys the connector always derives itself; they cannot be
overridden. */
+ private static final List<String> RESERVED_REST_PROPERTIES =
+ ImmutableList.of(
+ IcebergPropertiesConstants.ICEBERG_CATALOG_TYPE,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_URI,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_WAREHOUSE,
+ IcebergPropertiesConstants.ICEBERG_REST_CATALOG_PREFIX);
+
public static class IcebergPropertiesConverterHolder {
private static final IcebergPropertiesConverter INSTANCE = new
IcebergPropertiesConverter();
}
@@ -68,6 +85,194 @@ public class IcebergPropertiesConverter implements
PropertiesConverter {
return all;
}
+ /**
+ * Builds Spark Iceberg catalog properties that route requests through the
Gravitino Iceberg REST
+ * server, regardless of the catalog's actual storage backend (hive/jdbc).
This is the only path
+ * on which temporary credentials work: the Iceberg REST protocol vends a
fresh credential per
+ * table access, so static secrets are intentionally not carried over from
{@code
+ * gravitinoProperties}. Static-key credentials for the non-REST path are
unaffected; they are
+ * still injected via {@link
org.apache.gravitino.credential.CredentialPropertyUtils}.
+ *
+ * @param gravitinoCatalogName the Gravitino catalog name, used as both
{@code warehouse} (for the
+ * initial catalog discovery request) and {@code prefix} (for every
subsequent request path
+ * segment) so the REST server resolves the same Gravitino catalog
+ * @param restUri the Iceberg REST server endpoint to route through,
resolved by the caller
+ * @param gravitinoProperties the Gravitino catalog properties
+ * @param icebergRestClientConfig operator-level Iceberg REST client config
(e.g. {@code
+ * rest.auth.type}), applied after catalog-level {@code spark.bypass.}
overrides since it is a
+ * cluster-wide operational setting
+ * @return the Spark Iceberg catalog properties
+ */
+ Map<String, String> buildIcebergRestProperties(
+ String gravitinoCatalogName,
+ String restUri,
+ Map<String, String> gravitinoProperties,
+ Map<String, String> icebergRestClientConfig) {
+ Preconditions.checkArgument(StringUtils.isNotBlank(restUri), "restUri
should not be blank");
+
+ Map<String, String> all = new HashMap<>();
+ // Later put/putAll calls override earlier ones for the same key; this is
call-order
+ // precedence, unrelated to HashMap's (unspecified) iteration order.
+ all.putAll(buildStorageProperties(gravitinoProperties));
+ all.put(
+ IcebergPropertiesConstants.ICEBERG_ACCESS_DELEGATION,
+
IcebergPropertiesConstants.ICEBERG_ACCESS_DELEGATION_VENDED_CREDENTIALS);
+ // The catalog's own spark.bypass properties override the defaults above,
so a renamed
+ // Iceberg client property can be worked around without a connector change.
+ all.putAll(extractSparkBypassProperties(gravitinoProperties));
+ all.putAll(icebergRestClientConfig);
+
+ reapplyReservedRestProperties(gravitinoCatalogName, restUri, all);
+ all.put(IcebergPropertiesConstants.ICEBERG_CATALOG_CACHE_ENABLED, "FALSE");
+ return all;
+ }
+
+ /**
+ * Re-derives the reserved routing keys ({@code type}/{@code uri}/{@code
warehouse}/{@code
+ * prefix}) on {@code all}, so that a source merged in after {@link
#buildIcebergRestProperties}
+ * (e.g. Spark catalog {@code options}) can never redirect a routed catalog.
+ *
+ * @param gravitinoCatalogName the Gravitino catalog name
+ * @param restUri the Iceberg REST server endpoint to route through
+ * @param all the properties to re-derive the reserved keys on, mutated in
place
+ */
+ void reapplyReservedRestProperties(
+ String gravitinoCatalogName, String restUri, Map<String, String> all) {
+ Map<String, String> derived =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.ICEBERG_CATALOG_TYPE,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_BACKEND_REST,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_URI,
+ restUri,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_WAREHOUSE,
+ gravitinoCatalogName,
+ IcebergPropertiesConstants.ICEBERG_REST_CATALOG_PREFIX,
+ gravitinoCatalogName);
+ warnOnReservedRestPropertyOverrides(gravitinoCatalogName, all, derived);
+ all.putAll(derived);
+ }
+
+ private Map<String, String> extractSparkBypassProperties(Map<String, String>
properties) {
+ Map<String, String> bypass = new HashMap<>();
+ if (properties != null) {
+ properties.forEach(
+ (k, v) -> {
+ if (k.startsWith(SPARK_PROPERTY_PREFIX)) {
+ bypass.put(k.substring(SPARK_PROPERTY_PREFIX.length()), v);
+ }
+ });
+ }
+ return bypass;
+ }
+
+ private void warnOnReservedRestPropertyOverrides(
+ String gravitinoCatalogName, Map<String, String> config, Map<String,
String> derived) {
+ for (String reserved : RESERVED_REST_PROPERTIES) {
+ String current = config.get(reserved);
+ // A reserved key already set to its own derived value (from a prior
call on the same
+ // catalog) is not an override; only a value that actually differs is
worth warning about.
+ if (current != null && !current.equals(derived.get(reserved))) {
+ LOG.warn(
+ "Property '{}' set on catalog '{}' is ignored; the connector
always derives it when "
+ + "routing through the Iceberg REST server.",
+ reserved,
+ gravitinoCatalogName);
+ }
+ }
+ }
+
+ /**
+ * Derives the storage-related Iceberg client config from the catalog's
warehouse location. Only
+ * non-secret settings (native FileIO impl, custom endpoint, region,
path-style access) are
+ * carried over; static access keys and JDBC credentials are deliberately
excluded since the REST
+ * path vends its own temporary credentials.
+ */
+ private Map<String, String> buildStorageProperties(Map<String, String>
gravitinoProperties) {
+ Map<String, String> icebergProperties =
+ IcebergPropertiesUtils.toIcebergCatalogProperties(gravitinoProperties);
+ Map<String, String> storageProperties = new HashMap<>();
+ copyIfPresent(icebergProperties,
IcebergPropertiesConstants.ICEBERG_IO_IMPL, storageProperties);
+
+ String warehouse = gravitinoProperties.get(IcebergConstants.WAREHOUSE);
+ String fileIoImpl = deriveFileIoImpl(warehouse);
+ if (fileIoImpl != null) {
+
storageProperties.putIfAbsent(IcebergPropertiesConstants.ICEBERG_IO_IMPL,
fileIoImpl);
+ } else if
(!storageProperties.containsKey(IcebergPropertiesConstants.ICEBERG_IO_IMPL)) {
+ warnOnSchemeWithoutNativeFileIo(gravitinoProperties, warehouse);
+ }
+
+ copyIfPresent(
+ icebergProperties, IcebergPropertiesConstants.ICEBERG_S3_ENDPOINT,
storageProperties);
+ copyIfPresent(
+ icebergProperties, IcebergPropertiesConstants.ICEBERG_AWS_S3_REGION,
storageProperties);
+ copyIfPresent(
+ icebergProperties,
+ IcebergPropertiesConstants.ICEBERG_S3_PATH_STYLE_ACCESS,
+ storageProperties);
+ copyIfPresent(
+ icebergProperties, IcebergPropertiesConstants.ICEBERG_OSS_ENDPOINT,
storageProperties);
+ return storageProperties;
+ }
+
+ /**
+ * Derives the native Iceberg FileIO implementation for a warehouse
location, or {@code null} if
+ * the scheme has none (e.g. {@code hdfs://}, {@code file://}).
Package-private so the routing
+ * decision can check whether a warehouse has a native FileIO without
duplicating the scheme list.
+ */
+ static String deriveFileIoImpl(String warehouse) {
+ if (StringUtils.isBlank(warehouse) || !warehouse.contains("://")) {
+ return null;
+ }
+ String scheme = StringUtils.substringBefore(warehouse,
"://").toLowerCase(Locale.ROOT);
+ switch (scheme) {
+ case "s3":
+ case "s3a":
+ case "s3n":
+ return IcebergPropertiesConstants.ICEBERG_S3_FILE_IO_IMPL;
+ case "gs":
+ return IcebergPropertiesConstants.ICEBERG_GCS_FILE_IO_IMPL;
+ case "abfs":
+ case "abfss":
+ case "wasb":
+ case "wasbs":
+ return IcebergPropertiesConstants.ICEBERG_ADLS_FILE_IO_IMPL;
+ case "oss":
+ return IcebergPropertiesConstants.ICEBERG_OSS_FILE_IO_IMPL;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Warns when a warehouse scheme has no native Iceberg FileIO but the
catalog vends credentials.
+ * Vended credentials are only consumed by Iceberg's native FileIO
implementations, so table
+ * access would otherwise fail at read time with a storage authentication
error far from its
+ * cause, unless {@code io-impl} is set explicitly on the catalog.
+ */
+ private void warnOnSchemeWithoutNativeFileIo(
+ Map<String, String> gravitinoProperties, String warehouse) {
+ if (StringUtils.isBlank(warehouse) || !warehouse.contains("://")) {
+ // hdfs and file legitimately have no native FileIO here, and neither
vends credentials.
+ return;
+ }
+ if
(StringUtils.isBlank(gravitinoProperties.get(CredentialConstants.CREDENTIAL_PROVIDERS)))
{
+ return;
+ }
+ LOG.warn(
+ "Warehouse '{}' has no native Iceberg FileIO for the credentials
vended by '{}' to be "
+ + "applied to; table access may fail to authenticate unless
'io-impl' is set "
+ + "explicitly. Schemes with a native FileIO: s3/s3a/s3n, gs,
abfs/abfss/wasb/wasbs, oss.",
+ warehouse,
+ gravitinoProperties.get(CredentialConstants.CREDENTIAL_PROVIDERS));
+ }
+
+ private void copyIfPresent(Map<String, String> source, String key,
Map<String, String> target) {
+ String value = source.get(key);
+ if (StringUtils.isNotBlank(value)) {
+ target.put(key, value);
+ }
+ }
+
@Override
public Map<String, String> toGravitinoTableProperties(Map<String, String>
properties) {
return new HashMap<>(properties);
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergRestOAuthConfig.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergRestOAuthConfig.java
new file mode 100644
index 0000000000..6d419c3c4b
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/iceberg/IcebergRestOAuthConfig.java
@@ -0,0 +1,135 @@
+/*
+ * 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.spark.connector.iceberg;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.auth.AuthProperties;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.spark.SparkConf;
+
+/**
+ * Derives the Iceberg REST client's OAuth2 configuration, reusing Gravitino's
own OAuth2 client
+ * settings by default. When auth is explicitly set to a non-OAuth2 type, a
bearer {@code token} is
+ * supplied, or any other namespaced {@code rest.auth.*} property is present,
the caller-supplied
+ * config is treated as already complete and passed through unvalidated; {@link
+ * #validateLegacyOAuth2} only runs on the reuse-from-Gravitino /
explicit-legacy-property path.
+ */
+class IcebergRestOAuthConfig {
+
+ static final String AUTH_TYPE = "rest.auth.type";
+ static final String AUTH_TYPE_OAUTH2 = "oauth2";
+ static final String TOKEN = "token";
+ static final String CREDENTIAL = "credential";
+ static final String OAUTH2_SERVER_URI = "oauth2-server-uri";
+ static final String SCOPE = "scope";
+
+ // Properties whose presence alone implies the user is explicitly
configuring legacy OAuth2
+ // (triggering validation), as opposed to optional extras that ride along
with it. TOKEN is not
+ // included here: it already short-circuits to a plain pass-through above.
+ private static final Set<String> OAUTH2_TRIGGER_PROPERTIES =
ImmutableSet.of(CREDENTIAL);
+
+ private IcebergRestOAuthConfig() {}
+
+ static Map<String, String> resolve(SparkConf sparkConf, Map<String, String>
explicitRestConfig) {
+ Map<String, String> result = new HashMap<>(explicitRestConfig);
+ String explicitAuthType = result.get(AUTH_TYPE);
+ if (StringUtils.isNotBlank(explicitAuthType)
+ && !AUTH_TYPE_OAUTH2.equalsIgnoreCase(explicitAuthType)) {
+ return result;
+ }
+
+ // A bearer token is a complete OAuth2 authentication mechanism by itself.
Preserve the legacy
+ // behavior and do not require client-credential properties alongside it.
+ if (StringUtils.isNotBlank(result.get(TOKEN))) {
+ return result;
+ }
+
+ // Namespaced authentication settings are self-contained, including
token-only OAuth2. Do not
+ // mix the legacy OAuth2 client properties used by the reuse path into
that configuration.
+ if (result.keySet().stream()
+ .anyMatch(key -> key.startsWith("rest.auth.") &&
!AUTH_TYPE.equals(key))) {
+ return result;
+ }
+
+ String authType =
+ sparkConf.get(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE,
AuthProperties.SIMPLE_AUTH_TYPE);
+ boolean reuseOAuth2 =
+
sparkConf.getBoolean(GravitinoSparkConfig.GRAVITINO_ICEBERG_REUSE_OAUTH2, true)
+ && AuthProperties.isOAuth2(authType);
+ boolean hasExplicitLegacyOAuth2 =
+ StringUtils.equalsIgnoreCase(explicitAuthType, AUTH_TYPE_OAUTH2)
+ ||
result.keySet().stream().anyMatch(OAUTH2_TRIGGER_PROPERTIES::contains);
+ if (!reuseOAuth2 && !hasExplicitLegacyOAuth2) {
+ return result;
+ }
+
+ result.putIfAbsent(AUTH_TYPE, AUTH_TYPE_OAUTH2);
+ if (reuseOAuth2) {
+ putIfConfigured(
+ result,
+ CREDENTIAL,
+ sparkConf.get(GravitinoSparkConfig.GRAVITINO_OAUTH2_CREDENTIAL,
null));
+ putIfConfigured(
+ result, SCOPE,
sparkConf.get(GravitinoSparkConfig.GRAVITINO_OAUTH2_SCOPE, null));
+ if (!result.containsKey(OAUTH2_SERVER_URI)) {
+ String serverUri =
sparkConf.get(GravitinoSparkConfig.GRAVITINO_OAUTH2_URI, null);
+ String tokenPath =
sparkConf.get(GravitinoSparkConfig.GRAVITINO_OAUTH2_PATH, null);
+ if (StringUtils.isNotBlank(serverUri) &&
StringUtils.isNotBlank(tokenPath)) {
+ result.put(OAUTH2_SERVER_URI, joinUri(serverUri, tokenPath));
+ }
+ }
+ }
+
+ validateLegacyOAuth2(result);
+ return result;
+ }
+
+ private static void putIfConfigured(Map<String, String> config, String key,
String value) {
+ if (!config.containsKey(key) && StringUtils.isNotBlank(value)) {
+ config.put(key, value);
+ }
+ }
+
+ // scope is intentionally not required: Iceberg itself defaults it to
"catalog". The server URI
+ // is required rather than left to Iceberg's own default, so the client
credential is never
+ // posted to an unintended token endpoint.
+ private static void validateLegacyOAuth2(Map<String, String> config) {
+ List<String> missing = new ArrayList<>();
+ for (String key : ImmutableSet.of(AUTH_TYPE, CREDENTIAL,
OAUTH2_SERVER_URI)) {
+ if (StringUtils.isBlank(config.get(key))) {
+ missing.add(key);
+ }
+ }
+ if (!missing.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Incomplete Iceberg REST OAuth2 configuration; missing: " +
String.join(", ", missing));
+ }
+ }
+
+ private static String joinUri(String serverUri, String tokenPath) {
+ return StringUtils.removeEnd(serverUri, "/") + "/" +
StringUtils.removeStart(tokenPath, "/");
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
index ea0e03aa9d..59636ec0ef 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
@@ -112,6 +112,7 @@ public class GravitinoDriverPlugin implements DriverPlugin {
this.catalogManager =
GravitinoCatalogManager.create(
+ metalake,
() ->
createGravitinoClient(
gravitinoUri, metalake, conf, sc.sparkUser(),
gravitinoClientConfig));
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestGravitinoCatalogManager.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestGravitinoCatalogManager.java
new file mode 100644
index 0000000000..d91034066a
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestGravitinoCatalogManager.java
@@ -0,0 +1,107 @@
+/*
+ * 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.spark.connector.catalog;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.gravitino.client.GravitinoClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/** Verifies GravitinoCatalogManager's lazy caching of the Iceberg REST
endpoint discovery. */
+public class TestGravitinoCatalogManager {
+
+ private static final String METALAKE_NAME = "test_metalake";
+
+ @AfterEach
+ void closeManager() {
+ try {
+ GravitinoCatalogManager.get().close();
+ } catch (IllegalStateException e) {
+ // The test closed the manager itself.
+ }
+ }
+
+ @Test
+ void testIcebergRestUriIsCachedAfterSuccessfulDiscovery() {
+ AtomicInteger discoveryCalls = new AtomicInteger();
+ GravitinoCatalogManager manager =
+ createManager(
+ invocation -> {
+ discoveryCalls.incrementAndGet();
+ return Optional.of("http://irc:9001/iceberg");
+ });
+
+ Optional<String> first = manager.getIcebergRestUri();
+ Optional<String> second = manager.getIcebergRestUri();
+
+ assertEquals(Optional.of("http://irc:9001/iceberg"), first);
+ assertSame(first, second, "A cached discovery result must not be
recomputed");
+ assertEquals(1, discoveryCalls.get());
+ }
+
+ @Test
+ void testNoIcebergRestUriDiscoveredIsAlsoCached() {
+ AtomicInteger discoveryCalls = new AtomicInteger();
+ GravitinoCatalogManager manager =
+ createManager(
+ invocation -> {
+ discoveryCalls.incrementAndGet();
+ return Optional.empty();
+ });
+
+ manager.getIcebergRestUri();
+ manager.getIcebergRestUri();
+
+ assertEquals(1, discoveryCalls.get(), "A negative discovery result must be
cached too");
+ }
+
+ @Test
+ void testIcebergRestUriDiscoveryFailurePropagatesAndIsNotCached() {
+ AtomicInteger discoveryCalls = new AtomicInteger();
+ GravitinoCatalogManager manager =
+ createManager(
+ invocation -> {
+ if (discoveryCalls.incrementAndGet() == 1) {
+ throw new RuntimeException("Gravitino server unreachable");
+ }
+ return Optional.of("http://irc:9001/iceberg");
+ });
+
+ assertThrows(RuntimeException.class, manager::getIcebergRestUri);
+ Optional<String> second = manager.getIcebergRestUri();
+
+ assertEquals(Optional.of("http://irc:9001/iceberg"), second);
+ assertEquals(2, discoveryCalls.get(), "A failed discovery must be retried
on the next call");
+ }
+
+ private GravitinoCatalogManager createManager(
+ org.mockito.stubbing.Answer<Optional<String>> answer) {
+ GravitinoClient client = mock(GravitinoClient.class);
+ when(client.icebergRestServiceUri(anyString())).thenAnswer(answer);
+ return GravitinoCatalogManager.create(METALAKE_NAME, () -> client);
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
index af980f7309..c30265f089 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
@@ -66,7 +66,7 @@ public class TestGravitinoGlueCatalog {
// GravitinoGlueCatalog extends BaseCatalog which calls
GravitinoCatalogManager.get()
// in its constructor, so we must initialize the manager first.
GravitinoClient mockClient = mock(GravitinoClient.class);
- GravitinoCatalogManager.create(() -> mockClient);
+ GravitinoCatalogManager.create("test_metalake", () -> mockClient);
}
@AfterAll
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestGravitinoIcebergCatalogRestRouting.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestGravitinoIcebergCatalogRestRouting.java
new file mode 100644
index 0000000000..5261ab73b9
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestGravitinoIcebergCatalogRestRouting.java
@@ -0,0 +1,246 @@
+/*
+ * 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.spark.connector.iceberg;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
+import org.apache.gravitino.credential.CredentialConstants;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.spark.SparkConf;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests Iceberg REST endpoint selection for Spark catalogs. */
+public class TestGravitinoIcebergCatalogRestRouting {
+
+ @Test
+ void testRoutingDisabledSkipsDiscovery() {
+ Map<String, String> sessionConfig =
+
ImmutableMap.of(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED,
"false");
+ AtomicBoolean discoveryCalled = new AtomicBoolean();
+
+ // Routing disabled must skip even the credential-providers check: a
catalog with static
+ // credentials is exactly what stays on the legacy path here.
+ Optional<String> result =
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hiveProperties(),
+ sessionConfig::get,
+ () -> {
+ discoveryCalled.set(true);
+ return Optional.of("http://discovered/iceberg");
+ });
+
+ Assertions.assertFalse(result.isPresent());
+ Assertions.assertFalse(discoveryCalled.get());
+ }
+
+ @Test
+ void testEligibleCatalogWithNativeFileIoAndNoCredentialProvidersFails() {
+ IllegalStateException exception =
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ s3HiveProperties(), key -> null, Optional::empty));
+
+ Assertions.assertTrue(
+
exception.getMessage().contains(CredentialConstants.CREDENTIAL_PROVIDERS));
+ }
+
+ @Test
+ void testEligibleCatalogWithoutNativeFileIoSkipsCredentialProvidersCheck() {
+ // hdfs:// has no native Iceberg FileIO, so routing carries no risk of
dropping static
+ // credentials even without credential-providers configured.
+ Optional<String> result =
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hiveProperties(), key -> null, () ->
Optional.of("http://discovered/iceberg"));
+
+ Assertions.assertEquals("http://discovered/iceberg", result.get());
+ }
+
+ @Test
+ void testManualUriTakesPrecedenceOverDiscovery() {
+ Map<String, String> sessionConfig =
+ ImmutableMap.of(
+ GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_URI,
"http://configured/iceberg");
+ AtomicBoolean discoveryCalled = new AtomicBoolean();
+
+ Optional<String> result =
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hivePropertiesWithCredentialProviders(),
+ sessionConfig::get,
+ () -> {
+ discoveryCalled.set(true);
+ return Optional.of("http://discovered/iceberg");
+ });
+
+ Assertions.assertEquals("http://configured/iceberg", result.get());
+ Assertions.assertFalse(discoveryCalled.get());
+ }
+
+ @Test
+ void testNoEndpointFailsWhenRoutingNotExplicitlySet() {
+ IllegalStateException exception =
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hivePropertiesWithCredentialProviders(), key -> null,
Optional::empty));
+
+ Assertions.assertTrue(
+ exception
+ .getMessage()
+
.contains(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED));
+ }
+
+ @Test
+ void testNoEndpointFailsWhenRoutingExplicitlyEnabled() {
+ Map<String, String> sessionConfig =
+
ImmutableMap.of(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED,
"true");
+
+ IllegalStateException exception =
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hivePropertiesWithCredentialProviders(),
sessionConfig::get, Optional::empty));
+
+ Assertions.assertTrue(
+ exception
+ .getMessage()
+
.contains(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED));
+ }
+
+ @Test
+ void testInvalidRoutingSettingFails() {
+ Map<String, String> sessionConfig =
+
ImmutableMap.of(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED,
"yes");
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hiveProperties(), sessionConfig::get, Optional::empty));
+
+ Assertions.assertTrue(
+ exception
+ .getMessage()
+
.contains(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED));
+ }
+
+ @Test
+ void testDiscoveryFailureFailsWhenRoutingNotExplicitlySet() {
+ RuntimeException discoveryFailure = new RuntimeException("connection
refused");
+
+ IllegalStateException exception =
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hivePropertiesWithCredentialProviders(),
+ key -> null,
+ () -> {
+ throw discoveryFailure;
+ }));
+
+ Assertions.assertSame(discoveryFailure, exception.getCause());
+ }
+
+ @Test
+ void testDiscoveryFailureFailsWhenRoutingExplicitlyEnabled() {
+ Map<String, String> sessionConfig =
+
ImmutableMap.of(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED,
"true");
+ RuntimeException discoveryFailure = new RuntimeException("connection
refused");
+
+ IllegalStateException exception =
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ hivePropertiesWithCredentialProviders(),
+ sessionConfig::get,
+ () -> {
+ throw discoveryFailure;
+ }));
+
+ Assertions.assertSame(discoveryFailure, exception.getCause());
+ Assertions.assertTrue(
+ exception
+ .getMessage()
+
.contains(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED));
+ Assertions.assertTrue(
+
exception.getMessage().contains(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_URI));
+ }
+
+ @Test
+ void testNonRoutableBackendSkipsDiscovery() {
+ AtomicBoolean discoveryCalled = new AtomicBoolean();
+
+ Optional<String> result =
+ GravitinoIcebergCatalog.resolveIcebergRestUri(
+ ImmutableMap.of(IcebergConstants.CATALOG_BACKEND, "rest"),
+ key -> null,
+ () -> {
+ discoveryCalled.set(true);
+ return Optional.of("http://discovered/iceberg");
+ });
+
+ Assertions.assertFalse(result.isPresent());
+ Assertions.assertFalse(discoveryCalled.get());
+ }
+
+ @Test
+ void testAutoRoutedRestClientConfigStripsPrefixFromRealSparkConf() {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(
+ GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_CONFIG_PREFIX +
"rest.auth.type", "basic");
+ sparkConf.set(
+ GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_CONFIG_PREFIX +
"rest.auth.basic.username",
+ "admin");
+
+ Map<String, String> result =
+
GravitinoIcebergCatalog.getAutoRoutedIcebergRestClientConfig(sparkConf);
+
+ Assertions.assertEquals("basic", result.get("rest.auth.type"));
+ Assertions.assertEquals("admin", result.get("rest.auth.basic.username"));
+ }
+
+ private static ImmutableMap<String, String> hiveProperties() {
+ return ImmutableMap.of(IcebergConstants.CATALOG_BACKEND, "hive");
+ }
+
+ private static ImmutableMap<String, String>
hivePropertiesWithCredentialProviders() {
+ return ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "hive",
+ CredentialConstants.CREDENTIAL_PROVIDERS,
+ "s3-token");
+ }
+
+ private static ImmutableMap<String, String> s3HiveProperties() {
+ return ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND, "hive",
+ IcebergConstants.WAREHOUSE, "s3://bucket/path");
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergPropertiesConverter.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergPropertiesConverter.java
index 643a977cdb..d314a1ed99 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergPropertiesConverter.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergPropertiesConverter.java
@@ -20,6 +20,7 @@
package org.apache.gravitino.spark.connector.iceberg;
import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
import java.util.Map;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogBackend;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
@@ -140,4 +141,193 @@ public class TestIcebergPropertiesConverter {
"custom-warehouse"),
properties);
}
+
+ @Test
+ void testIcebergRestPropertiesFromHiveBackendExcludesStaticCredentials() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_URI,
+ "thrift://hive-metastore:9083",
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
+ "s3://bucket/warehouse",
+ "s3-access-key-id",
+ "AKIDEXAMPLE",
+ "s3-secret-access-key",
+ "secret");
+
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg",
gravitinoProperties, ImmutableMap.of());
+
+ Assertions.assertEquals(
+ "rest",
properties.get(IcebergPropertiesConstants.ICEBERG_CATALOG_TYPE));
+ Assertions.assertEquals(
+ "http://gravitino:9001/iceberg",
+ properties.get(IcebergPropertiesConstants.ICEBERG_CATALOG_URI));
+ Assertions.assertEquals(
+ "my_catalog",
properties.get(IcebergPropertiesConstants.ICEBERG_CATALOG_WAREHOUSE));
+ Assertions.assertEquals("my_catalog", properties.get("prefix"));
+ Assertions.assertEquals(
+ "vended-credentials",
properties.get("header.X-Iceberg-Access-Delegation"));
+ Assertions.assertEquals(
+ "org.apache.iceberg.aws.s3.S3FileIO",
properties.get(IcebergConstants.IO_IMPL));
+
+ // The REST protocol vends its own credentials, so no static secret should
be present.
+
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_S3_ACCESS_KEY_ID));
+
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_S3_SECRET_ACCESS_KEY));
+
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_JDBC_USER));
+
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_JDBC_PASSWORD));
+ }
+
+ @Test
+ void testIcebergRestPropertiesFromJdbcBackendExcludesStaticCredentials() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_BACKEND_JDBC,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_URI,
+ "jdbc:postgresql://db:5432/iceberg",
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_JDBC_USER,
+ "user",
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_JDBC_PASSWORD,
+ "passwd");
+
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg",
gravitinoProperties, ImmutableMap.of());
+
+ Assertions.assertEquals(
+ "rest",
properties.get(IcebergPropertiesConstants.ICEBERG_CATALOG_TYPE));
+
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_JDBC_USER));
+
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_JDBC_PASSWORD));
+ }
+
+ @Test
+ void testIcebergRestPropertiesRespectsExplicitIoImpl() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
+ "s3://bucket/warehouse",
+ IcebergConstants.IO_IMPL,
+ "com.example.CustomFileIO");
+
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg",
gravitinoProperties, ImmutableMap.of());
+
+ Assertions.assertEquals("com.example.CustomFileIO",
properties.get(IcebergConstants.IO_IMPL));
+ }
+
+ @Test
+ void testIcebergRestPropertiesDerivesNativeFileIoPerScheme() {
+ Assertions.assertEquals(
+ "org.apache.iceberg.gcp.gcs.GCSFileIO",
fileIoForWarehouse("gs://bucket/warehouse"));
+ Assertions.assertEquals(
+ "org.apache.iceberg.azure.adlsv2.ADLSFileIO",
+
fileIoForWarehouse("abfss://[email protected]/warehouse"));
+ Assertions.assertEquals(
+ "org.apache.iceberg.aliyun.oss.OSSFileIO",
fileIoForWarehouse("oss://bucket/warehouse"));
+ }
+
+ private String fileIoForWarehouse(String warehouse) {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
+ warehouse);
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg",
gravitinoProperties, ImmutableMap.of());
+ return properties.get(IcebergConstants.IO_IMPL);
+ }
+
+ @Test
+ void testIcebergRestPropertiesWarehouseSchemeWithoutNativeFileIo() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
+ "hdfs://namenode/warehouse");
+
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg",
gravitinoProperties, ImmutableMap.of());
+
+ Assertions.assertFalse(properties.containsKey(IcebergConstants.IO_IMPL));
+ }
+
+ @Test
+ void testIcebergRestPropertiesBypassAndReservedKeyOverride() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE,
+ "spark.bypass.some-custom-property",
+ "custom-value",
+ // Attempting to override a reserved routing key via spark.bypass
must be ignored.
+ "spark.bypass." + IcebergPropertiesConstants.ICEBERG_CATALOG_URI,
+ "http://attacker-controlled/iceberg");
+
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg",
gravitinoProperties, ImmutableMap.of());
+
+ Assertions.assertEquals("custom-value",
properties.get("some-custom-property"));
+ Assertions.assertEquals(
+ "http://gravitino:9001/iceberg",
+ properties.get(IcebergPropertiesConstants.ICEBERG_CATALOG_URI));
+ }
+
+ @Test
+ void testReapplyReservedRestPropertiesUndoesSparkOptionsOverride() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE);
+ Map<String, String> properties =
+ new HashMap<>(
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog",
+ "http://gravitino:9001/iceberg",
+ gravitinoProperties,
+ ImmutableMap.of()));
+
+ // Simulate a Spark-level catalog option redirecting the reserved routing
keys, the way
+ // buildAutoRoutedIcebergRestProperties merges `options` in after
buildIcebergRestProperties.
+ properties.put(IcebergPropertiesConstants.ICEBERG_CATALOG_URI,
"http://attacker-controlled");
+ properties.put(IcebergPropertiesConstants.ICEBERG_REST_CATALOG_PREFIX,
"other_catalog");
+
+ icebergPropertiesConverter.reapplyReservedRestProperties(
+ "my_catalog", "http://gravitino:9001/iceberg", properties);
+
+ Assertions.assertEquals(
+ "http://gravitino:9001/iceberg",
+ properties.get(IcebergPropertiesConstants.ICEBERG_CATALOG_URI));
+ Assertions.assertEquals(
+ "my_catalog",
properties.get(IcebergPropertiesConstants.ICEBERG_REST_CATALOG_PREFIX));
+ }
+
+ @Test
+ void testIcebergRestPropertiesRestClientConfigPassthrough() {
+ Map<String, String> gravitinoProperties =
+ ImmutableMap.of(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND_HIVE);
+
+ Map<String, String> properties =
+ icebergPropertiesConverter.buildIcebergRestProperties(
+ "my_catalog",
+ "http://gravitino:9001/iceberg",
+ gravitinoProperties,
+ ImmutableMap.of("rest.auth.type", "basic",
"rest.auth.basic.username", "admin"));
+
+ Assertions.assertEquals("basic", properties.get("rest.auth.type"));
+ Assertions.assertEquals("admin",
properties.get("rest.auth.basic.username"));
+ }
}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergRestOAuthConfig.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergRestOAuthConfig.java
new file mode 100644
index 0000000000..e4fae252e3
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/iceberg/TestIcebergRestOAuthConfig.java
@@ -0,0 +1,205 @@
+/*
+ * 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.spark.connector.iceberg;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import java.util.Map;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.spark.SparkConf;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests automatic reuse of Gravitino OAuth2 client settings for Iceberg REST
catalogs. */
+public class TestIcebergRestOAuthConfig {
+
+ @Test
+ void testDerivesIcebergOAuthConfig() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com/",
"/oauth/token");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
Collections.emptyMap());
+
+ Assertions.assertEquals("oauth2",
result.get(IcebergRestOAuthConfig.AUTH_TYPE));
+ Assertions.assertEquals("alice:secret",
result.get(IcebergRestOAuthConfig.CREDENTIAL));
+ Assertions.assertEquals("openid",
result.get(IcebergRestOAuthConfig.SCOPE));
+ Assertions.assertEquals(
+ "https://identity.example.com/oauth/token",
+ result.get(IcebergRestOAuthConfig.OAUTH2_SERVER_URI));
+ }
+
+ @Test
+ void testExplicitRestAuthenticationTakesPrecedence() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+ Map<String, String> explicit =
+ ImmutableMap.of("rest.auth.type", "basic", "rest.auth.basic.username",
"admin");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
explicit);
+
+ Assertions.assertEquals(explicit, result);
+ }
+
+ @Test
+ void testExplicitOAuthCredentialOverridesReusedCredential() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+ Map<String, String> explicit = ImmutableMap.of("credential", "irc:secret");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
explicit);
+
+ Assertions.assertEquals("oauth2",
result.get(IcebergRestOAuthConfig.AUTH_TYPE));
+ Assertions.assertEquals("irc:secret",
result.get(IcebergRestOAuthConfig.CREDENTIAL));
+ Assertions.assertEquals("openid",
result.get(IcebergRestOAuthConfig.SCOPE));
+ Assertions.assertEquals(
+ "https://identity.example.com/oauth/token",
+ result.get(IcebergRestOAuthConfig.OAUTH2_SERVER_URI));
+ }
+
+ @Test
+ void testAllExplicitOAuthPropertiesOverrideReusedProperties() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+ Map<String, String> explicit =
+ ImmutableMap.of(
+ IcebergRestOAuthConfig.AUTH_TYPE,
+ "oauth2",
+ IcebergRestOAuthConfig.CREDENTIAL,
+ "irc:secret",
+ IcebergRestOAuthConfig.SCOPE,
+ "irc-scope",
+ IcebergRestOAuthConfig.OAUTH2_SERVER_URI,
+ "https://irc-identity.example.com/token");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
explicit);
+
+ Assertions.assertEquals(explicit, result);
+ }
+
+ @Test
+ void testPartialOAuthConfigFailsWhenReuseIsUnavailable() {
+ SparkConf sparkConf = new SparkConf(false);
+ Map<String, String> explicit = ImmutableMap.of("credential", "irc:secret");
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> IcebergRestOAuthConfig.resolve(sparkConf, explicit));
+
+ // scope is not required: Iceberg itself defaults it. oauth2-server-uri is
still required.
+
Assertions.assertTrue(exception.getMessage().contains("oauth2-server-uri"));
+ }
+
+ @Test
+ void testOAuthConfigWithoutScopeSucceeds() {
+ SparkConf sparkConf = new SparkConf(false);
+ Map<String, String> explicit =
+ ImmutableMap.of(
+ "credential",
+ "irc:secret",
+ "oauth2-server-uri",
+ "https://irc-identity.example.com/token");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
explicit);
+
+ Assertions.assertEquals("oauth2",
result.get(IcebergRestOAuthConfig.AUTH_TYPE));
+ Assertions.assertEquals("irc:secret",
result.get(IcebergRestOAuthConfig.CREDENTIAL));
+ Assertions.assertEquals(
+ "https://irc-identity.example.com/token",
+ result.get(IcebergRestOAuthConfig.OAUTH2_SERVER_URI));
+ Assertions.assertFalse(result.containsKey(IcebergRestOAuthConfig.SCOPE));
+ }
+
+ @Test
+ void testNamespacedOAuthTokenIsKeptIndependent() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+ Map<String, String> explicit =
+ ImmutableMap.of("rest.auth.type", "oauth2", "rest.auth.oauth2.token",
"irc-token");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
explicit);
+
+ Assertions.assertEquals(explicit, result);
+ }
+
+ @Test
+ void testLegacyBearerTokenIsKeptIndependent() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+ Map<String, String> explicit = ImmutableMap.of("token", "irc-token");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
explicit);
+
+ Assertions.assertEquals(explicit, result);
+ }
+
+ @Test
+ void testNonAuthenticationRestPropertiesDoNotDisableAutomaticReuse() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+
+ Map<String, String> result =
+ IcebergRestOAuthConfig.resolve(
+ sparkConf, ImmutableMap.of("header.X-Iceberg-Custom", "value"));
+
+ Assertions.assertEquals("value", result.get("header.X-Iceberg-Custom"));
+ Assertions.assertEquals("oauth2",
result.get(IcebergRestOAuthConfig.AUTH_TYPE));
+ Assertions.assertEquals("alice:secret",
result.get(IcebergRestOAuthConfig.CREDENTIAL));
+ }
+
+ @Test
+ void testCanDisableOAuthReuse() {
+ SparkConf sparkConf = oauthSparkConf("https://identity.example.com",
"oauth/token");
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_ICEBERG_REUSE_OAUTH2,
"false");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
Collections.emptyMap());
+
+ Assertions.assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testDoesNotDeriveConfigForOtherAuthenticationTypes() {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE, "simple");
+
+ Map<String, String> result = IcebergRestOAuthConfig.resolve(sparkConf,
Collections.emptyMap());
+
+ Assertions.assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testReuseFailsWhenGravitinoOAuthConfigIsIncomplete() {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE, "oauth2");
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_OAUTH2_CREDENTIAL,
"alice:secret");
+ // GRAVITINO_OAUTH2_URI/PATH are intentionally left unset, so
oauth2-server-uri can't be
+ // derived: this is the default-reuse path, not an explicit override, and
must still validate.
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> IcebergRestOAuthConfig.resolve(sparkConf,
Collections.emptyMap()));
+
+
Assertions.assertTrue(exception.getMessage().contains("oauth2-server-uri"));
+ }
+
+ private SparkConf oauthSparkConf(String serverUri, String tokenPath) {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE, "oauth2");
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_OAUTH2_URI, serverUri);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_OAUTH2_PATH, tokenPath);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_OAUTH2_CREDENTIAL,
"alice:secret");
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_OAUTH2_SCOPE, "openid");
+ return sparkConf;
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
index fcd2c2d819..e5191a7d2b 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
@@ -28,6 +28,7 @@ import java.util.HashMap;
import java.util.Map;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.client.GravitinoMetalake;
import org.apache.gravitino.function.FunctionDefinition;
import org.apache.gravitino.function.FunctionDefinitions;
@@ -84,6 +85,27 @@ public abstract class SparkEnvIT extends SparkUtilIT {
return true;
}
+ /**
+ * Whether the Iceberg REST auxiliary service should run with {@code
dynamic-config-provider}
+ * (routing requests to whichever Gravitino catalog the request's {@code
prefix} names) instead of
+ * the default single-catalog {@code static-config-provider}. Tests that
verify the Spark
+ * connector's Iceberg REST auto-discovery/routing need this.
+ */
+ protected boolean useDynamicIcebergRestConfigProvider() {
+ return false;
+ }
+
+ /**
+ * Extra Spark session configs applied on top of the base set in {@link
#initSparkEnv()}. Tests
+ * that intentionally exercise a non-REST Iceberg catalog backend without a
discoverable Iceberg
+ * REST endpoint (e.g. {@code useDynamicIcebergRestConfigProvider() ==
false}) must disable
+ * Iceberg REST routing here, since routing is otherwise required and fails
catalog initialization
+ * when no endpoint is discoverable.
+ */
+ protected Map<String, String> getExtraSparkConfigs() {
+ return Collections.emptyMap();
+ }
+
/** Returns the Gravitino {@link Catalog} for the catalog under test. */
protected Catalog getGravitinoCatalog() {
return client.loadMetalake(metalakeName).loadCatalog(getCatalogName());
@@ -232,24 +254,36 @@ public abstract class SparkEnvIT extends SparkUtilIT {
private void initIcebergRestServiceEnv() {
super.ignoreIcebergAuxRestService = false;
Map<String, String> icebergRestServiceConfigs = new HashMap<>();
- icebergRestServiceConfigs.put(
- "gravitino."
- + icebergRestServiceName
- + "."
- + IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
- IcebergPropertiesConstants.ICEBERG_CATALOG_BACKEND_HIVE);
- icebergRestServiceConfigs.put(
- "gravitino."
- + icebergRestServiceName
- + "."
- + IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_URI,
- hiveMetastoreUri);
- icebergRestServiceConfigs.put(
- "gravitino."
- + icebergRestServiceName
- + "."
- + IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
- warehouse);
+ if (useDynamicIcebergRestConfigProvider()) {
+ icebergRestServiceConfigs.put(
+ "gravitino."
+ + icebergRestServiceName
+ + "."
+ + IcebergConstants.ICEBERG_REST_CATALOG_CONFIG_PROVIDER,
+ IcebergConstants.DYNAMIC_ICEBERG_CATALOG_CONFIG_PROVIDER_NAME);
+ icebergRestServiceConfigs.put(
+ "gravitino." + icebergRestServiceName + "." +
IcebergConstants.GRAVITINO_METALAKE,
+ metalakeName);
+ } else {
+ icebergRestServiceConfigs.put(
+ "gravitino."
+ + icebergRestServiceName
+ + "."
+ + IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_BACKEND_HIVE);
+ icebergRestServiceConfigs.put(
+ "gravitino."
+ + icebergRestServiceName
+ + "."
+ + IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_URI,
+ hiveMetastoreUri);
+ icebergRestServiceConfigs.put(
+ "gravitino."
+ + icebergRestServiceName
+ + "."
+ + IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
+ warehouse);
+ }
registerCustomConfigs(icebergRestServiceConfigs);
}
@@ -280,6 +314,7 @@ public abstract class SparkEnvIT extends SparkUtilIT {
.set("hive.exec.dynamic.partition.mode", "nonstrict")
.set("spark.sql.warehouse.dir", warehouse)
.set("spark.sql.session.timeZone", TIME_ZONE_UTC);
+ getExtraSparkConfigs().forEach(sparkConf::set);
sparkSession =
SparkSession.builder()
.master("local[1]")
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogHiveBackendIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogHiveBackendIT.java
index c160f97103..fd1edd6784 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogHiveBackendIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogHiveBackendIT.java
@@ -18,9 +18,11 @@
*/
package org.apache.gravitino.spark.connector.integration.test.iceberg;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import java.util.Map;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
import org.apache.gravitino.spark.connector.iceberg.IcebergPropertiesConstants;
import org.junit.jupiter.api.Tag;
@@ -28,6 +30,14 @@ import org.junit.jupiter.api.Tag;
@Tag("gravitino-docker-test")
public abstract class SparkIcebergCatalogHiveBackendIT extends
SparkIcebergCatalogIT {
+ @Override
+ protected Map<String, String> getExtraSparkConfigs() {
+ // This class deliberately exercises the legacy Hive backend without a
discoverable Iceberg
+ // REST endpoint (useDynamicIcebergRestConfigProvider() is false), so
routing must be disabled
+ // explicitly.
+ return
ImmutableMap.of(GravitinoSparkConfig.GRAVITINO_ICEBERG_REST_ROUTING_ENABLED,
"false");
+ }
+
@Override
protected Map<String, String> getCatalogConfigs() {
Map<String, String> catalogProperties = Maps.newHashMap();
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT.java
new file mode 100644
index 0000000000..80b726429b
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT.java
@@ -0,0 +1,76 @@
+/*
+ * 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.spark.connector.integration.test.iceberg;
+
+import com.google.common.collect.Maps;
+import java.util.Map;
+import org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalog;
+import org.apache.gravitino.spark.connector.iceberg.IcebergPropertiesConstants;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.spark.sql.connector.catalog.CatalogPlugin;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledIf;
+
+/**
+ * This class configures the Gravitino Iceberg catalog with a Hive backend,
but runs the Iceberg
+ * REST auxiliary service in {@code dynamic-config-provider} mode so the Spark
connector
+ * auto-discovers it and routes the catalog through the Iceberg REST protocol
instead of talking to
+ * the Hive metastore directly. All test cases inherited from {@link
SparkIcebergCatalogIT} exercise
+ * this REST-routed path end to end.
+ */
+@Tag("gravitino-docker-test")
+// Spark connector uses a low Iceberg version, couldn't work with Iceberg REST
server with high
+// Iceberg version in embedded mode.
+@DisabledIf("org.apache.gravitino.integration.test.util.ITUtils#isEmbedded")
+public abstract class SparkIcebergCatalogRestRoutingIT extends
SparkIcebergCatalogIT {
+
+ @Override
+ protected boolean useDynamicIcebergRestConfigProvider() {
+ return true;
+ }
+
+ @Override
+ protected Map<String, String> getCatalogConfigs() {
+ Map<String, String> catalogProperties = Maps.newHashMap();
+ catalogProperties.put(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_BACKEND,
+ IcebergPropertiesConstants.ICEBERG_CATALOG_BACKEND_HIVE);
+ catalogProperties.put(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_WAREHOUSE,
warehouse);
+ catalogProperties.put(
+ IcebergPropertiesConstants.GRAVITINO_ICEBERG_CATALOG_URI,
hiveMetastoreUri);
+ return catalogProperties;
+ }
+
+ @Test
+ void testCatalogIsRoutedThroughIcebergRestServer() {
+ CatalogPlugin catalogPlugin =
+
getSparkSession().sessionState().catalogManager().catalog(getCatalogName());
+ Assertions.assertInstanceOf(GravitinoIcebergCatalog.class, catalogPlugin);
+
+ Catalog icebergCatalog = ((GravitinoIcebergCatalog)
catalogPlugin).icebergCatalog();
+ Assertions.assertEquals(
+ "org.apache.iceberg.rest.RESTCatalog",
+ icebergCatalog.getClass().getName(),
+ "The hive-backed catalog should have been routed through the
discovered Iceberg REST "
+ + "server, instead of Iceberg's native HiveCatalog.");
+ }
+}
diff --git
a/spark-connector/v3.3/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT33.java
b/spark-connector/v3.3/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT33.java
new file mode 100644
index 0000000000..181cf6a0bc
--- /dev/null
+++
b/spark-connector/v3.3/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT33.java
@@ -0,0 +1,62 @@
+/*
+ * 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.spark.connector.integration.test.iceberg;
+
+import
org.apache.gravitino.spark.connector.integration.test.util.SparkMetadataColumnInfo;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.junit.jupiter.api.condition.DisabledIf;
+
+/**
+ * Spark 3.3 integration tests for Iceberg REST routing.
+ *
+ * <p>Spark 3.3 pins Iceberg to 1.8.x ({@code iceberg4spark33}); Gravitino's
lakehouse Iceberg stack
+ * is 1.11. {@link DisabledIf} skips embedded mode to avoid classpath
conflicts. Deploy-mode tests
+ * run a 1.8.x client against Gravitino's 1.11 REST server and cover basic
catalog CRUD only; they
+ * do not exercise Iceberg 1.11 REST changes such as {@code file-scan-tasks}
scan planning or the
+ * configurable multi-level namespace separator contract.
+ */
+@DisabledIf("org.apache.gravitino.integration.test.util.ITUtils#isEmbedded")
+public class SparkIcebergCatalogRestRoutingIT33 extends
SparkIcebergCatalogRestRoutingIT {
+ @Override
+ protected boolean supportsFunction() {
+ // Spark 3.3 does not support function operations
+ return false;
+ }
+
+ /**
+ * Spark 3.3 uses Iceberg 1.8.x; its {@code SparkTable#metadataColumns()}
exposes five columns,
+ * not the seven row-lineage columns added in Iceberg 1.11.
+ */
+ @Override
+ protected SparkMetadataColumnInfo[] getIcebergMetadataColumns() {
+ return new SparkMetadataColumnInfo[] {
+ new SparkMetadataColumnInfo("_spec_id", DataTypes.IntegerType, false),
+ new SparkMetadataColumnInfo(
+ "_partition",
+ DataTypes.createStructType(
+ new StructField[] {DataTypes.createStructField("name",
DataTypes.StringType, true)}),
+ true),
+ new SparkMetadataColumnInfo("_file", DataTypes.StringType, false),
+ new SparkMetadataColumnInfo("_pos", DataTypes.LongType, false),
+ new SparkMetadataColumnInfo("_deleted", DataTypes.BooleanType, false)
+ };
+ }
+}
diff --git
a/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT34.java
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT34.java
new file mode 100644
index 0000000000..ae23429b09
--- /dev/null
+++
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT34.java
@@ -0,0 +1,25 @@
+/*
+ * 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.spark.connector.integration.test.iceberg;
+
+import org.junit.jupiter.api.condition.DisabledIf;
+
+@DisabledIf("org.apache.gravitino.integration.test.util.ITUtils#isEmbedded")
+public class SparkIcebergCatalogRestRoutingIT34 extends
SparkIcebergCatalogRestRoutingIT {}
diff --git a/spark-connector/v3.5/spark/build.gradle.kts
b/spark-connector/v3.5/spark/build.gradle.kts
index 4b2a7b7402..d59088d5bf 100644
--- a/spark-connector/v3.5/spark/build.gradle.kts
+++ b/spark-connector/v3.5/spark/build.gradle.kts
@@ -151,6 +151,8 @@ dependencies {
testImplementation(libs.mysql.driver)
testImplementation(libs.postgresql.driver)
testImplementation(libs.testcontainers)
+ testImplementation(libs.aws.policy)
+ testImplementation(project(":iceberg:iceberg-common"))
// org.apache.iceberg.rest.RESTSerializers#registerAll(ObjectMapper) has
different method signature for iceberg-core and iceberg-spark-runtime package,
we must make sure iceberg-core is in front to start up MiniGravitino server.
testImplementation("org.apache.iceberg:iceberg-core:$icebergVersion")
diff --git
a/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT35.java
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT35.java
new file mode 100644
index 0000000000..7a091bc0c5
--- /dev/null
+++
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestRoutingIT35.java
@@ -0,0 +1,25 @@
+/*
+ * 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.spark.connector.integration.test.iceberg;
+
+import org.junit.jupiter.api.condition.DisabledIf;
+
+@DisabledIf("org.apache.gravitino.integration.test.util.ITUtils#isEmbedded")
+public class SparkIcebergCatalogRestRoutingIT35 extends
SparkIcebergCatalogRestRoutingIT {}
diff --git
a/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestS3CredentialVendingIT35.java
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestS3CredentialVendingIT35.java
new file mode 100644
index 0000000000..606131a4a9
--- /dev/null
+++
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/iceberg/SparkIcebergCatalogRestS3CredentialVendingIT35.java
@@ -0,0 +1,239 @@
+/*
+ * 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.spark.connector.integration.test.iceberg;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.auth.AuthenticatorType;
+import org.apache.gravitino.authorization.Owner;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObject;
+import org.apache.gravitino.authorization.SecurableObjects;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.integration.test.container.ContainerSuite;
+import org.apache.gravitino.integration.test.container.PostgreSQLContainer;
+import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.integration.test.util.JwksMockServerHelper;
+import org.apache.gravitino.integration.test.util.OAuthMockDataProvider;
+import org.apache.gravitino.integration.test.util.TestDatabaseName;
+import org.apache.gravitino.server.authentication.OAuthConfig;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalog;
+import org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin;
+import org.apache.spark.SparkConf;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.catalog.CatalogPlugin;
+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.condition.EnabledIfEnvironmentVariable;
+
+/** Spark 3.5 integration test for OAuth2 IRC routing with AWS credential
vending. */
+@Tag("gravitino-docker-test")
+@EnabledIfEnvironmentVariable(named = "GRAVITINO_TEST_CLOUD_IT", matches =
"true")
+public class SparkIcebergCatalogRestS3CredentialVendingIT35 extends BaseIT {
+
+ private static final String ADMIN = "gravitino";
+ private static final String ALICE = "alice";
+ private static final String ALICE_CREDENTIAL = "alice:alice-secret";
+ private static final String AUDIENCE = "service1";
+ private static final String METALAKE = "spark35_irc_oauth";
+ private static final String CATALOG = "iceberg_pg";
+ private static final String SCHEMA = "aws_vending";
+ private static final String TABLE = "spark35_test";
+ private static final String ROLE = "spark35-irc-role";
+ private static final String AWS_ROLE_ARN = System.getenv("AWS_ROLE_ARN");
+
+ private final ContainerSuite containerSuite = ContainerSuite.getInstance();
+
+ private JwksMockServerHelper mockServer;
+ private SparkSession spark;
+
+ @BeforeAll
+ @Override
+ public void startIntegrationTest() throws Exception {
+ initializeOAuthServer();
+ initializeGravitinoConfig();
+ initializePostgreSQL();
+ super.startIntegrationTest();
+ initializeMetadata();
+ spark = createSparkSession();
+ }
+
+ @AfterAll
+ @Override
+ public void stopIntegrationTest() throws IOException, InterruptedException {
+ if (spark != null) {
+ spark.stop();
+ }
+ if (mockServer != null) {
+ mockServer.close();
+ }
+ super.stopIntegrationTest();
+ }
+
+ @Test
+ void testPostgreSQLCatalogRoutesThroughOAuthIrcAndVendsS3Credentials() {
+ CatalogPlugin catalogPlugin =
spark.sessionState().catalogManager().catalog(CATALOG);
+ Assertions.assertInstanceOf(GravitinoIcebergCatalog.class, catalogPlugin);
+ org.apache.iceberg.catalog.Catalog icebergCatalog =
+ ((GravitinoIcebergCatalog) catalogPlugin).icebergCatalog();
+ Assertions.assertEquals(
+ "org.apache.iceberg.rest.RESTCatalog",
icebergCatalog.getClass().getName());
+
+ spark.sql(String.format("DROP TABLE IF EXISTS %s.%s.%s", CATALOG, SCHEMA,
TABLE));
+ spark.sql(
+ String.format(
+ "CREATE TABLE %s.%s.%s (id BIGINT, data STRING) USING iceberg",
+ CATALOG, SCHEMA, TABLE));
+ spark.sql(
+ String.format(
+ "INSERT INTO %s.%s.%s VALUES (1, 'one'), (2, 'two')", CATALOG,
SCHEMA, TABLE));
+
+ List<Row> rows =
+ spark
+ .sql(String.format("SELECT id, data FROM %s.%s.%s ORDER BY id",
CATALOG, SCHEMA, TABLE))
+ .collectAsList();
+ Assertions.assertEquals(2, rows.size());
+ Assertions.assertEquals(1L, rows.get(0).getLong(0));
+ Assertions.assertEquals("one", rows.get(0).getString(1));
+ Assertions.assertEquals(2L, rows.get(1).getLong(0));
+ Assertions.assertEquals("two", rows.get(1).getString(1));
+
+ Optional<Owner> owner =
+ client
+ .loadMetalake(METALAKE)
+ .getOwner(
+ MetadataObjects.of(
+ ImmutableList.of(CATALOG, SCHEMA, TABLE),
MetadataObject.Type.TABLE));
+ Assertions.assertTrue(owner.isPresent());
+ Assertions.assertEquals(ALICE, owner.get().name());
+
+ spark.sql(String.format("DROP TABLE %s.%s.%s PURGE", CATALOG, SCHEMA,
TABLE));
+ }
+
+ private void initializeOAuthServer() throws Exception {
+ mockServer = JwksMockServerHelper.create("spark35-irc-kid");
+ Instant expiration = Instant.now().plusSeconds(3600);
+ String adminToken = mockServer.mintToken(ADMIN, AUDIENCE, expiration);
+ String aliceToken = mockServer.mintToken(ALICE, AUDIENCE, expiration);
+ mockServer.registerUserToken(ADMIN, adminToken);
+ mockServer.registerUserToken(ALICE, aliceToken);
+ mockServer.setFallbackToken(adminToken);
+
OAuthMockDataProvider.getInstance().setTokenData(adminToken.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private void initializeGravitinoConfig() {
+ ignoreIcebergAuxRestService = false;
+ Map<String, String> configs = Maps.newHashMap();
+ configs.put(Configs.AUTHENTICATORS.getKey(),
AuthenticatorType.OAUTH.name().toLowerCase());
+ configs.put(OAuthConfig.SERVICE_AUDIENCE.getKey(), AUDIENCE);
+ configs.put(
+ OAuthConfig.TOKEN_VALIDATOR_CLASS.getKey(),
+ "org.apache.gravitino.server.authentication.JwksTokenValidator");
+ configs.put(OAuthConfig.JWKS_URI.getKey(), mockServer.jwksUri());
+ configs.put(OAuthConfig.PRINCIPAL_FIELDS.getKey(), "sub");
+ configs.put(Configs.ENABLE_AUTHORIZATION.getKey(), "true");
+ configs.put(Configs.SERVICE_ADMINS.getKey(), ADMIN);
+ configs.put("gravitino.iceberg-rest.catalog-config-provider",
"dynamic-config-provider");
+ configs.put("gravitino.iceberg-rest.gravitino-metalake", METALAKE);
+ registerCustomConfigs(configs);
+ }
+
+ private void initializePostgreSQL() {
+ containerSuite.startPostgreSQLContainer(
+ TestDatabaseName.PG_TEST_ICEBERG_CATALOG_MULTIPLE_JDBC_LOAD);
+ }
+
+ private void initializeMetadata() throws SQLException {
+ PostgreSQLContainer postgres = containerSuite.getPostgreSQLContainer();
+ TestDatabaseName database =
TestDatabaseName.PG_TEST_ICEBERG_CATALOG_MULTIPLE_JDBC_LOAD;
+ client.createMetalake(METALAKE, "", new HashMap<>());
+ GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+ metalake.addUser(ALICE);
+
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("catalog-backend", "jdbc");
+ properties.put("uri", postgres.getJdbcUrl(database));
+ properties.put("jdbc-driver", postgres.getDriverClassName(database));
+ properties.put("jdbc-user", postgres.getUsername());
+ properties.put("jdbc-password", postgres.getPassword());
+ properties.put(
+ "warehouse",
+ String.format(
+ "s3://%s/gravitino-irc-oauth-demo/spark-3.5-pg",
System.getenv("AWS_S3_TEST_BUCKET")));
+ properties.put("credential-providers", "s3-token");
+ properties.put("s3-access-key-id", System.getenv("AWS_ACCESS_KEY_ID"));
+ properties.put("s3-secret-access-key",
System.getenv("AWS_SECRET_ACCESS_KEY"));
+ properties.put("s3-region", System.getenv("AWS_DEFAULT_REGION"));
+ properties.put("s3-role-arn", AWS_ROLE_ARN);
+ properties.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO");
+ Catalog catalog =
+ metalake.createCatalog(
+ CATALOG, Catalog.Type.RELATIONAL, "lakehouse-iceberg", "",
properties);
+ catalog.asSchemas().createSchema(SCHEMA, "", new HashMap<>());
+
+ SecurableObject access =
+ SecurableObjects.ofCatalog(
+ CATALOG,
+ ImmutableList.of(
+ Privileges.UseCatalog.allow(),
+ Privileges.UseSchema.allow(),
+ Privileges.CreateTable.allow(),
+ Privileges.ModifyTable.allow(),
+ Privileges.SelectTable.allow()));
+ metalake.createRole(ROLE, new HashMap<>(), ImmutableList.of(access));
+ metalake.grantRolesToUser(ImmutableList.of(ROLE), ALICE);
+ }
+
+ private SparkSession createSparkSession() {
+ SparkConf conf =
+ new SparkConf()
+ .set("spark.plugins", GravitinoSparkPlugin.class.getName())
+ .set(GravitinoSparkConfig.GRAVITINO_URI, serverUri)
+ .set(GravitinoSparkConfig.GRAVITINO_METALAKE, METALAKE)
+ .set(GravitinoSparkConfig.GRAVITINO_ENABLE_ICEBERG_SUPPORT, "true")
+ .set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE, "oauth2")
+ .set(GravitinoSparkConfig.GRAVITINO_OAUTH2_URI,
mockServer.baseUri())
+ .set(GravitinoSparkConfig.GRAVITINO_OAUTH2_PATH, "token")
+ .set(GravitinoSparkConfig.GRAVITINO_OAUTH2_CREDENTIAL,
ALICE_CREDENTIAL)
+ .set(GravitinoSparkConfig.GRAVITINO_OAUTH2_SCOPE, "openid");
+ return SparkSession.builder()
+ .master("local[1]")
+ .appName("SparkIcebergCatalogRestS3CredentialVendingIT35")
+ .config(conf)
+ .getOrCreate();
+ }
+}