Skip to main content

risingwave_connector/connector_common/iceberg/
mod.rs

1// Copyright 2024 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub mod compaction;
16mod jni_catalog;
17mod mock_catalog;
18mod storage_catalog;
19
20use std::collections::HashMap;
21use std::sync::{Arc, LazyLock};
22
23use ::iceberg::io::{
24    S3_ACCESS_KEY_ID, S3_ASSUME_ROLE_ARN, S3_ENDPOINT, S3_REGION, S3_SECRET_ACCESS_KEY,
25};
26use ::iceberg::table::Table;
27use ::iceberg::{Catalog, CatalogBuilder, TableIdent};
28use anyhow::{Context, anyhow};
29use iceberg::io::object_cache::ObjectCache;
30use iceberg::io::{
31    ADLS_ACCOUNT_KEY, ADLS_ACCOUNT_NAME, ADLS_AUTHORITY_HOST, ADLS_CLIENT_ID, ADLS_CLIENT_SECRET,
32    ADLS_TENANT_ID, AZBLOB_ACCOUNT_KEY, AZBLOB_ACCOUNT_NAME, AZBLOB_ENDPOINT, GCS_CREDENTIALS_JSON,
33    GCS_DISABLE_CONFIG_LOAD, S3_DISABLE_CONFIG_LOAD, S3_PATH_STYLE_ACCESS,
34};
35use iceberg_catalog_glue::{AWS_ACCESS_KEY_ID, AWS_REGION_NAME, AWS_SECRET_ACCESS_KEY};
36use iceberg_storage_opendal::OpenDalResolvingStorageFactory;
37use moka::future::Cache as MokaCache;
38use phf::{Set, phf_set};
39use risingwave_common::bail;
40use risingwave_common::error::IcebergError;
41use risingwave_common::util::deployment::Deployment;
42use risingwave_common::util::env_var::env_var_is_true;
43use serde::Deserialize;
44use serde_with::serde_as;
45use url::Url;
46use uuid::Uuid;
47use with_options::WithOptions;
48
49use crate::connector_common::common::DISABLE_DEFAULT_CREDENTIAL;
50use crate::connector_common::iceberg::storage_catalog::StorageCatalogConfig;
51use crate::deserialize_optional_bool_from_string;
52use crate::enforce_secret::EnforceSecret;
53use crate::error::ConnectorResult;
54
55#[serde_as]
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize, WithOptions)]
57pub struct IcebergCommon {
58    // Catalog type supported by iceberg, such as "storage", "rest".
59    // If not set, we use "storage" as default.
60    #[serde(rename = "catalog.type")]
61    pub catalog_type: Option<String>,
62    #[serde(rename = "s3.region")]
63    pub s3_region: Option<String>,
64    #[serde(rename = "s3.endpoint")]
65    pub s3_endpoint: Option<String>,
66    #[serde(rename = "s3.access.key")]
67    pub s3_access_key: Option<String>,
68    #[serde(rename = "s3.secret.key")]
69    pub s3_secret_key: Option<String>,
70    #[serde(rename = "s3.iam_role_arn")]
71    pub s3_iam_role_arn: Option<String>,
72
73    #[serde(rename = "glue.access.key")]
74    pub glue_access_key: Option<String>,
75    #[serde(rename = "glue.secret.key")]
76    pub glue_secret_key: Option<String>,
77    #[serde(rename = "glue.iam_role_arn")]
78    pub glue_iam_role_arn: Option<String>,
79    #[serde(rename = "glue.region")]
80    pub glue_region: Option<String>,
81    #[serde(rename = "glue.endpoint")]
82    pub glue_endpoint: Option<String>,
83    /// AWS Client id, can be omitted for storage catalog or when
84    /// caller's AWS account ID matches glue id
85    #[serde(rename = "glue.id")]
86    pub glue_id: Option<String>,
87
88    #[serde(rename = "gcs.credential")]
89    pub gcs_credential: Option<String>,
90
91    #[serde(rename = "azblob.account_name")]
92    pub azblob_account_name: Option<String>,
93    #[serde(rename = "azblob.account_key")]
94    pub azblob_account_key: Option<String>,
95    #[serde(rename = "azblob.endpoint_url")]
96    pub azblob_endpoint_url: Option<String>,
97
98    #[serde(rename = "adlsgen2.account_name")]
99    pub adlsgen2_account_name: Option<String>,
100    #[serde(rename = "adlsgen2.account_key")]
101    pub adlsgen2_account_key: Option<String>,
102    #[serde(rename = "adlsgen2.endpoint")]
103    pub adlsgen2_endpoint: Option<String>,
104    #[serde(rename = "adlsgen2.tenant_id")]
105    pub adlsgen2_tenant_id: Option<String>,
106    #[serde(rename = "adlsgen2.client_id")]
107    pub adlsgen2_client_id: Option<String>,
108    #[serde(rename = "adlsgen2.client_secret")]
109    pub adlsgen2_client_secret: Option<String>,
110    #[serde(rename = "adlsgen2.authority_host")]
111    pub adlsgen2_authority_host: Option<String>,
112
113    /// Path of iceberg warehouse.
114    #[serde(rename = "warehouse.path")]
115    pub warehouse_path: Option<String>,
116    /// Catalog name, default value is risingwave.
117    #[serde(rename = "catalog.name")]
118    pub catalog_name: Option<String>,
119    /// URI of iceberg catalog, only applicable in rest catalog.
120    #[serde(rename = "catalog.uri")]
121    pub catalog_uri: Option<String>,
122    /// Credential for accessing iceberg catalog, only applicable in rest catalog.
123    /// A credential to exchange for a token in the `OAuth2` client credentials flow.
124    #[serde(rename = "catalog.credential")]
125    pub catalog_credential: Option<String>,
126    /// token for accessing iceberg catalog, only applicable in rest catalog.
127    /// A Bearer token which will be used for interaction with the server.
128    #[serde(rename = "catalog.token")]
129    pub catalog_token: Option<String>,
130    /// `oauth2_server_uri` for accessing iceberg catalog, only applicable in rest catalog.
131    /// Token endpoint URI to fetch token from if the Rest Catalog is not the authorization server.
132    #[serde(rename = "catalog.oauth2_server_uri")]
133    pub catalog_oauth2_server_uri: Option<String>,
134    /// scope for accessing iceberg catalog, only applicable in rest catalog.
135    /// Additional scope for `OAuth2`.
136    #[serde(rename = "catalog.scope")]
137    pub catalog_scope: Option<String>,
138
139    /// The signing region to use when signing requests to the REST catalog.
140    #[serde(rename = "catalog.rest.signing_region")]
141    pub rest_signing_region: Option<String>,
142
143    /// The signing name to use when signing requests to the REST catalog.
144    #[serde(rename = "catalog.rest.signing_name")]
145    pub rest_signing_name: Option<String>,
146
147    /// Whether to use `SigV4` for signing requests to the REST catalog.
148    #[serde(
149        rename = "catalog.rest.sigv4_enabled",
150        default,
151        deserialize_with = "deserialize_optional_bool_from_string"
152    )]
153    pub rest_sigv4_enabled: Option<bool>,
154
155    #[serde(
156        rename = "s3.path.style.access",
157        default,
158        deserialize_with = "deserialize_optional_bool_from_string"
159    )]
160    pub s3_path_style_access: Option<bool>,
161    /// Enable config load. This parameter set to true will load warehouse credentials from the environment. Only allowed to be used in a self-hosted environment.
162    #[serde(default, deserialize_with = "deserialize_optional_bool_from_string")]
163    pub enable_config_load: Option<bool>,
164
165    /// This is only used by iceberg engine to enable the hosted catalog.
166    #[serde(
167        rename = "hosted_catalog",
168        default,
169        deserialize_with = "deserialize_optional_bool_from_string"
170    )]
171    pub hosted_catalog: Option<bool>,
172
173    /// The HTTP header to be used in catalog requests.
174    /// Example:
175    /// `catalog.header = "key1=value1;key2=value2;key3=value3"`
176    /// For Google Cloud Lakehouse Iceberg REST catalogs, set
177    /// `catalog.header = "x-goog-user-project=PROJECT_ID"` to specify the billing project.
178    /// Explain the format of the header:
179    /// - Each header is a key-value pair, separated by an '='.
180    /// - Multiple headers can be specified, separated by a ';'.
181    #[serde(rename = "catalog.header")]
182    pub catalog_header: Option<String>,
183
184    /// Enable vended credentials for Iceberg REST catalog.
185    /// For Google Cloud Lakehouse Iceberg REST catalogs, this sends
186    /// `X-Iceberg-Access-Delegation: vended-credentials`.
187    #[serde(default, deserialize_with = "deserialize_optional_bool_from_string")]
188    pub vended_credentials: Option<bool>,
189
190    /// Security type for REST catalog authentication.
191    /// Supported values: `none`, `oauth2`, `google`.
192    /// When set to `google`, native REST catalogs authenticate using
193    /// `gcs.credential` or Google Application Default Credentials (ADC).
194    /// JNI REST catalogs use Java's `GoogleAuthManager` with ADC;
195    /// `gcs.credential` only configures Rust `FileIO` storage credentials.
196    /// Vended credentials use the native Rust REST catalog.
197    #[serde(rename = "catalog.security")]
198    pub catalog_security: Option<String>,
199
200    /// OAuth-based scopes for Google authentication.
201    /// Comma-separated list of OAuth-based scopes to request.
202    /// Only applicable when `catalog.security` is set to `google`.
203    /// Default: <https://www.googleapis.com/auth/cloud-platform>
204    #[serde(rename = "gcp.auth.scopes")]
205    pub gcp_auth_scopes: Option<String>,
206
207    /// Custom `FileIO` implementation class for the Iceberg catalog.
208    /// Allows specifying a custom `FileIO` implementation instead of the default.
209    /// Examples:
210    /// - `org.apache.iceberg.aws.s3.S3FileIO` for Amazon S3 (default)
211    /// - `org.apache.iceberg.gcp.gcs.GCSFileIO` for Google Cloud Storage
212    /// - `org.apache.iceberg.azure.adlsv2.ADLSFileIO` for Azure Data Lake Storage Gen2
213    /// Applies only to JNI catalogs. Native REST catalogs route storage by URI
214    /// and support GCS vended credentials without a Java `FileIO` class.
215    #[serde(rename = "catalog.io_impl")]
216    pub catalog_io_impl: Option<String>,
217}
218
219// Matches iceberg::io::object_cache default size (32MB).
220// TODO: change it after object cache get refactored.
221const DEFAULT_OBJECT_CACHE_SIZE_BYTES: u64 = 32 * 1024 * 1024;
222const SHARED_OBJECT_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
223const SHARED_OBJECT_CACHE_MAX_TABLES: u64 =
224    SHARED_OBJECT_CACHE_BUDGET_BYTES / DEFAULT_OBJECT_CACHE_SIZE_BYTES;
225
226/// Default Microsoft Entra (AAD) authority host for public Azure. Sovereign-cloud
227/// users override via `adlsgen2.authority_host`.
228const ADLS_DEFAULT_AUTHORITY_HOST: &str = "https://login.microsoftonline.com";
229
230impl EnforceSecret for IcebergCommon {
231    const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
232        "s3.access.key",
233        "s3.secret.key",
234        "gcs.credential",
235        "catalog.credential",
236        "catalog.token",
237        "catalog.oauth2_server_uri",
238        "adlsgen2.account_key",
239        "adlsgen2.client_secret",
240        "glue.access.key",
241        "glue.secret.key",
242    };
243}
244
245#[serde_as]
246#[derive(Debug, Clone, PartialEq, Eq, Deserialize, WithOptions)]
247#[serde(deny_unknown_fields)]
248pub struct IcebergTableIdentifier {
249    #[serde(rename = "database.name")]
250    pub database_name: Option<String>,
251    /// Table name or namespace-qualified table name. Dots are treated as
252    /// Iceberg namespace separators.
253    #[serde(rename = "table.name")]
254    pub table_name: String,
255}
256
257impl IcebergTableIdentifier {
258    pub fn database_name(&self) -> Option<&str> {
259        self.database_name.as_deref()
260    }
261
262    pub fn table_name(&self) -> &str {
263        &self.table_name
264    }
265
266    fn identifier_parts(&self) -> ConnectorResult<Vec<&str>> {
267        let mut parts = Vec::new();
268        if let Some(database_name) = &self.database_name {
269            parts.extend(database_name.split('.'));
270        }
271        parts.extend(self.table_name.split('.'));
272
273        if parts.iter().any(|part| part.is_empty()) {
274            bail!(
275                "Invalid iceberg table identifier '{}': identifier parts must not be empty",
276                self.full_identifier()
277            );
278        }
279
280        Ok(parts)
281    }
282
283    fn full_identifier(&self) -> String {
284        match &self.database_name {
285            Some(database_name) => format!("{}.{}", database_name, self.table_name),
286            None => self.table_name.clone(),
287        }
288    }
289
290    pub fn to_table_ident(&self) -> ConnectorResult<TableIdent> {
291        let ret = TableIdent::from_strs(self.identifier_parts()?);
292
293        Ok(ret.context("Failed to create table identifier")?)
294    }
295
296    pub fn validate(&self) -> ConnectorResult<()> {
297        self.identifier_parts().map(|_| ())
298    }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum IcebergCatalogRuntime {
303    NativeRust,
304    JavaJni,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum IcebergCatalogKind {
309    Storage,
310    Rest(IcebergCatalogRuntime),
311    Glue(IcebergCatalogRuntime),
312    Hive,
313    Jdbc,
314    Snowflake,
315    Mock,
316}
317
318impl IcebergCatalogKind {
319    fn resolve(common: &IcebergCommon) -> ConnectorResult<Self> {
320        let catalog_type = common.catalog_type();
321        let kind = match catalog_type {
322            "storage" => Self::Storage,
323            "rest" if common.vended_credentials() => Self::Rest(IcebergCatalogRuntime::NativeRust),
324            "rest" => Self::Rest(IcebergCatalogRuntime::JavaJni),
325            "rest_rust" => Self::Rest(IcebergCatalogRuntime::NativeRust),
326            "glue" => Self::Glue(IcebergCatalogRuntime::JavaJni),
327            "glue_rust" => Self::Glue(IcebergCatalogRuntime::NativeRust),
328            "hive" => Self::Hive,
329            "jdbc" => Self::Jdbc,
330            "snowflake" => Self::Snowflake,
331            #[cfg(any(test, madsim))]
332            "mock_v3" => Self::Mock,
333            "mock" => Self::Mock,
334            _ => {
335                bail!(
336                    "Unsupported catalog type: {}, only support `storage`, `rest`, `hive`, `jdbc`, `glue`, `snowflake`",
337                    catalog_type
338                )
339            }
340        };
341        Ok(kind)
342    }
343
344    pub fn is_rest(self) -> bool {
345        matches!(self, Self::Rest(_))
346    }
347
348    fn jni_impl(self) -> Option<JniCatalogImpl> {
349        match self {
350            Self::Rest(IcebergCatalogRuntime::JavaJni) => Some(JniCatalogImpl::Rest),
351            Self::Glue(IcebergCatalogRuntime::JavaJni) => Some(JniCatalogImpl::Glue),
352            Self::Hive => Some(JniCatalogImpl::Hive),
353            Self::Jdbc => Some(JniCatalogImpl::Jdbc),
354            Self::Snowflake => Some(JniCatalogImpl::Snowflake),
355            _ => None,
356        }
357    }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361enum JniCatalogImpl {
362    Hive,
363    Jdbc,
364    Snowflake,
365    Rest,
366    Glue,
367}
368
369impl JniCatalogImpl {
370    fn catalog_type(self) -> &'static str {
371        match self {
372            Self::Hive => "hive",
373            Self::Jdbc => "jdbc",
374            Self::Snowflake => "snowflake",
375            Self::Rest => "rest",
376            Self::Glue => "glue",
377        }
378    }
379
380    fn class_name(self) -> &'static str {
381        match self {
382            Self::Hive => "org.apache.iceberg.hive.HiveCatalog",
383            Self::Jdbc => "org.apache.iceberg.jdbc.JdbcCatalog",
384            Self::Snowflake => "org.apache.iceberg.snowflake.SnowflakeCatalog",
385            Self::Rest => "org.apache.iceberg.rest.RESTCatalog",
386            Self::Glue => "org.apache.iceberg.aws.glue.GlueCatalog",
387        }
388    }
389}
390
391enum CatalogBuildPlan {
392    Storage(StorageCatalogConfig),
393    NativeRest(HashMap<String, String>),
394    NativeGlue(HashMap<String, String>),
395    Jni {
396        file_io_props: HashMap<String, String>,
397        catalog_name: String,
398        catalog_impl: JniCatalogImpl,
399        java_catalog_props: HashMap<String, String>,
400    },
401    Mock,
402}
403
404pub struct ResolvedIcebergCatalogConfig<'a> {
405    common: &'a IcebergCommon,
406    kind: IcebergCatalogKind,
407    java_catalog_props: HashMap<String, String>,
408}
409
410impl<'a> ResolvedIcebergCatalogConfig<'a> {
411    pub fn kind(&self) -> IcebergCatalogKind {
412        self.kind
413    }
414
415    fn build_plan(&self) -> ConnectorResult<CatalogBuildPlan> {
416        match self.kind {
417            IcebergCatalogKind::Storage => self.common.build_storage_catalog_config(),
418            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust) => {
419                self.common.build_native_rest_catalog_props()
420            }
421            IcebergCatalogKind::Glue(IcebergCatalogRuntime::NativeRust) => {
422                self.common.build_native_glue_catalog_props()
423            }
424            IcebergCatalogKind::Mock => Ok(CatalogBuildPlan::Mock),
425            kind => {
426                let catalog_impl = kind.jni_impl().expect("java catalog kind has JNI impl");
427                let (file_io_props, java_catalog_props) = self
428                    .common
429                    .build_jni_catalog_configs(catalog_impl, &self.java_catalog_props)?;
430                Ok(CatalogBuildPlan::Jni {
431                    file_io_props,
432                    catalog_name: self.common.catalog_name(),
433                    catalog_impl,
434                    java_catalog_props,
435                })
436            }
437        }
438    }
439
440    pub async fn create_catalog(&self) -> ConnectorResult<Arc<dyn Catalog>> {
441        match self.build_plan()? {
442            CatalogBuildPlan::Storage(config) => {
443                let catalog = storage_catalog::StorageCatalog::new(config)?;
444                Ok(Arc::new(catalog))
445            }
446            CatalogBuildPlan::NativeRest(iceberg_configs) => {
447                let catalog = iceberg_catalog_rest::RestCatalogBuilder::default()
448                    .with_storage_factory(Arc::new(OpenDalResolvingStorageFactory::new()))
449                    .load("rest", iceberg_configs)
450                    .await
451                    .map_err(|e| anyhow!(IcebergError::from(e)))?;
452                Ok(Arc::new(catalog))
453            }
454            CatalogBuildPlan::NativeGlue(iceberg_configs) => {
455                let catalog = iceberg_catalog_glue::GlueCatalogBuilder::default()
456                    .load("glue", iceberg_configs)
457                    .await
458                    .map_err(|e| anyhow!(IcebergError::from(e)))?;
459                Ok(Arc::new(catalog))
460            }
461            CatalogBuildPlan::Jni {
462                file_io_props,
463                catalog_name,
464                catalog_impl,
465                java_catalog_props,
466            } => {
467                jni_catalog::JniCatalog::build_catalog(
468                    file_io_props,
469                    catalog_name,
470                    catalog_impl.class_name(),
471                    java_catalog_props,
472                )
473                .await
474            }
475            CatalogBuildPlan::Mock => Ok(Arc::new(mock_catalog::MockCatalog {})),
476        }
477    }
478
479    pub async fn load_table(&self, table: &IcebergTableIdentifier) -> ConnectorResult<Table> {
480        let catalog = self
481            .create_catalog()
482            .await
483            .context("Unable to load iceberg catalog")?;
484
485        let table_id = table
486            .to_table_ident()
487            .context("Unable to parse table name")?;
488
489        let table = catalog.load_table(&table_id).await?;
490        Ok(rebuild_table_with_shared_cache(table).await)
491    }
492}
493
494pub fn iceberg_java_catalog_props_from_options<'a>(
495    options: impl Iterator<Item = (&'a str, &'a str)>,
496) -> HashMap<String, String> {
497    options
498        .filter(|(k, _v)| {
499            k.starts_with("catalog.")
500                && k != &"catalog.uri"
501                && k != &"catalog.type"
502                && k != &"catalog.name"
503                && k != &"catalog.header"
504        })
505        .map(|(k, v)| (k[8..].to_owned(), v.to_owned()))
506        .collect()
507}
508
509impl IcebergCommon {
510    pub fn catalog_type(&self) -> &str {
511        self.catalog_type.as_deref().unwrap_or("storage")
512    }
513
514    pub fn vended_credentials(&self) -> bool {
515        self.vended_credentials.unwrap_or(false)
516    }
517
518    pub fn resolve_catalog_kind(&self) -> ConnectorResult<IcebergCatalogKind> {
519        IcebergCatalogKind::resolve(self)
520    }
521
522    pub fn is_rest_catalog(&self) -> ConnectorResult<bool> {
523        Ok(self.resolve_catalog_kind()?.is_rest())
524    }
525
526    pub fn resolve_catalog_config(
527        &self,
528        java_catalog_props: HashMap<String, String>,
529    ) -> ConnectorResult<ResolvedIcebergCatalogConfig<'_>> {
530        Ok(ResolvedIcebergCatalogConfig {
531            common: self,
532            kind: self.resolve_catalog_kind()?,
533            java_catalog_props,
534        })
535    }
536
537    fn glue_access_key(&self) -> Option<&str> {
538        self.glue_access_key
539            .as_deref()
540            .or(self.s3_access_key.as_deref())
541    }
542
543    fn glue_secret_key(&self) -> Option<&str> {
544        self.glue_secret_key
545            .as_deref()
546            .or(self.s3_secret_key.as_deref())
547    }
548
549    fn glue_region(&self) -> Option<&str> {
550        self.glue_region.as_deref().or(self.s3_region.as_deref())
551    }
552
553    fn glue_endpoint(&self) -> Option<&str> {
554        self.glue_endpoint
555            .as_deref()
556            .or_else(|| match self.resolve_catalog_kind() {
557                Ok(IcebergCatalogKind::Glue(IcebergCatalogRuntime::JavaJni)) => {
558                    self.catalog_uri.as_deref()
559                }
560                _ => None,
561            })
562    }
563
564    pub fn catalog_name(&self) -> String {
565        self.catalog_name
566            .as_ref()
567            .cloned()
568            .unwrap_or_else(|| "risingwave".to_owned())
569    }
570
571    pub fn headers(&self) -> ConnectorResult<HashMap<String, String>> {
572        let mut headers = HashMap::new();
573        let user_agent = match Deployment::current() {
574            Deployment::Ci => "RisingWave(CI)".to_owned(),
575            Deployment::Cloud => "RisingWave(Cloud)".to_owned(),
576            Deployment::Other => "RisingWave(OSS)".to_owned(),
577        };
578        if self.vended_credentials() {
579            headers.insert(
580                "X-Iceberg-Access-Delegation".to_owned(),
581                "vended-credentials".to_owned(),
582            );
583        }
584        headers.insert("User-Agent".to_owned(), user_agent);
585        if let Some(header) = &self.catalog_header {
586            for pair in header.split(';') {
587                let mut parts = pair.split('=');
588                if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
589                    headers.insert(key.to_owned(), value.to_owned());
590                } else {
591                    bail!("Invalid header format: {}", pair);
592                }
593            }
594        }
595        Ok(headers)
596    }
597
598    pub fn enable_config_load(&self) -> bool {
599        // If the env var is set to true, we disable the default config load. (Cloud environment)
600        if env_var_is_true(DISABLE_DEFAULT_CREDENTIAL) {
601            if matches!(self.enable_config_load, Some(true)) {
602                tracing::warn!(
603                    "`enable_config_load` can't be enabled in SaaS environment, the behavior might be unexpected"
604                );
605            }
606            return false;
607        }
608        self.enable_config_load.unwrap_or(false)
609    }
610
611    fn effective_s3_path_style_access(&self) -> bool {
612        // RisingWave historically inherited OpenDAL's path-style default. Iceberg now
613        // defaults to virtual-host style, so preserve existing connector behavior unless
614        // the user explicitly opts into virtual-host style with `false`.
615        self.s3_path_style_access.unwrap_or(true)
616    }
617
618    fn build_storage_catalog_config(&self) -> ConnectorResult<CatalogBuildPlan> {
619        let warehouse = self
620            .warehouse_path
621            .clone()
622            .ok_or_else(|| anyhow!("`warehouse.path` must be set in storage catalog"))?;
623        let url = Url::parse(warehouse.as_ref())
624            .map_err(|_| anyhow!("Invalid warehouse path: {}", warehouse))?;
625
626        let config = match url.scheme() {
627            "s3" | "s3a" => StorageCatalogConfig::S3(
628                storage_catalog::StorageCatalogS3Config::builder()
629                    .warehouse(warehouse)
630                    .access_key(self.s3_access_key.clone())
631                    .secret_key(self.s3_secret_key.clone())
632                    .region(self.s3_region.clone())
633                    .endpoint(self.s3_endpoint.clone())
634                    .path_style_access(Some(self.effective_s3_path_style_access()))
635                    .enable_config_load(Some(self.enable_config_load()))
636                    .build(),
637            ),
638            "gs" | "gcs" => StorageCatalogConfig::Gcs(
639                storage_catalog::StorageCatalogGcsConfig::builder()
640                    .warehouse(warehouse)
641                    .credential(self.gcs_credential.clone())
642                    .enable_config_load(Some(self.enable_config_load()))
643                    .build(),
644            ),
645            "azblob" => StorageCatalogConfig::Azblob(
646                storage_catalog::StorageCatalogAzblobConfig::builder()
647                    .warehouse(warehouse)
648                    .account_name(self.azblob_account_name.clone())
649                    .account_key(self.azblob_account_key.clone())
650                    .endpoint(self.azblob_endpoint_url.clone())
651                    .build(),
652            ),
653            scheme => bail!("Unsupported warehouse scheme: {}", scheme),
654        };
655
656        Ok(CatalogBuildPlan::Storage(config))
657    }
658
659    fn validate_native_rest_catalog_auth(
660        &self,
661        disable_default_credential: bool,
662    ) -> ConnectorResult<()> {
663        if disable_default_credential
664            && self
665                .catalog_security
666                .as_deref()
667                .is_some_and(|security| security.eq_ignore_ascii_case("google"))
668            && self.gcs_credential.is_none()
669        {
670            // Google token refresh uses ADC without a GCS credential, even when
671            // a catalog token or OAuth credential was explicitly provided.
672            bail!(
673                "`catalog.security=google` requires `gcs.credential` when `DISABLE_DEFAULT_CREDENTIAL=true`"
674            );
675        }
676        Ok(())
677    }
678
679    fn build_native_rest_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
680        self.validate_native_rest_catalog_auth(env_var_is_true(DISABLE_DEFAULT_CREDENTIAL))?;
681
682        let mut iceberg_configs = HashMap::new();
683        iceberg_configs.extend(self.adls_file_io_props()?);
684
685        if self
686            .catalog_security
687            .as_deref()
688            .is_some_and(|security| security.eq_ignore_ascii_case("google"))
689        {
690            iceberg_configs.insert("rest.auth.type".to_owned(), "google".to_owned());
691        }
692        if let Some(scopes) = &self.gcp_auth_scopes {
693            iceberg_configs.insert("gcp.auth.scopes".to_owned(), scopes.clone());
694        }
695
696        // check gcs credential or s3 access key and secret key
697        if let Some(gcs_credential) = &self.gcs_credential {
698            iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
699        } else {
700            if let Some(region) = &self.s3_region {
701                iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
702            }
703            if let Some(endpoint) = &self.s3_endpoint {
704                iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
705            }
706            if let Some(access_key) = &self.s3_access_key {
707                iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
708            }
709            if let Some(secret_key) = &self.s3_secret_key {
710                iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
711            }
712            if let Some(path_style_access) = &self.s3_path_style_access {
713                iceberg_configs.insert(
714                    S3_PATH_STYLE_ACCESS.to_owned(),
715                    path_style_access.to_string(),
716                );
717            }
718        };
719
720        if let Some(credential) = &self.catalog_credential {
721            iceberg_configs.insert("credential".to_owned(), credential.clone());
722        }
723        if let Some(token) = &self.catalog_token {
724            iceberg_configs.insert("token".to_owned(), token.clone());
725        }
726        if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
727            iceberg_configs.insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
728        }
729        if let Some(scope) = &self.catalog_scope {
730            iceberg_configs.insert("scope".to_owned(), scope.clone());
731        }
732
733        let headers = self.headers()?;
734        for (header_name, header_value) in headers {
735            iceberg_configs.insert(format!("header.{}", header_name), header_value);
736        }
737
738        iceberg_configs.insert(
739            iceberg_catalog_rest::REST_CATALOG_PROP_URI.to_owned(),
740            self.catalog_uri
741                .clone()
742                .with_context(|| "`catalog.uri` must be set in rest catalog".to_owned())?,
743        );
744        if let Some(warehouse_path) = &self.warehouse_path {
745            iceberg_configs.insert(
746                iceberg_catalog_rest::REST_CATALOG_PROP_WAREHOUSE.to_owned(),
747                warehouse_path.clone(),
748            );
749        }
750
751        Ok(CatalogBuildPlan::NativeRest(iceberg_configs))
752    }
753
754    fn adls_file_io_props(&self) -> ConnectorResult<HashMap<String, String>> {
755        let mut props = HashMap::new();
756        if let Some(endpoint) = &self.adlsgen2_endpoint {
757            props.insert(iceberg::io::ADLS_ENDPOINT.to_owned(), endpoint.clone());
758        }
759        let nonempty =
760            |value: &Option<String>| value.as_ref().filter(|s| !s.trim().is_empty()).cloned();
761        let tenant = nonempty(&self.adlsgen2_tenant_id);
762        let client = nonempty(&self.adlsgen2_client_id);
763        let secret = nonempty(&self.adlsgen2_client_secret);
764        let authority = nonempty(&self.adlsgen2_authority_host);
765        let account = nonempty(&self.adlsgen2_account_name);
766        let key = nonempty(&self.adlsgen2_account_key);
767        let any_sp =
768            tenant.is_some() || client.is_some() || secret.is_some() || authority.is_some();
769        if key.is_some() && any_sp {
770            bail!(
771                "adlsgen2: cannot configure both shared-key auth and service-principal auth simultaneously. Specify exactly one auth mode."
772            );
773        }
774        if any_sp && !(tenant.is_some() && client.is_some() && secret.is_some()) {
775            bail!(
776                "adlsgen2: service-principal auth requires all three of adlsgen2.tenant_id, adlsgen2.client_id, and adlsgen2.client_secret to be set"
777            );
778        }
779        if let Some(host) = &authority {
780            // The OAuth client sends its secret to this host. Only allow a bare HTTPS origin.
781            let parsed = Url::parse(host)
782                .map_err(|_| anyhow!("adlsgen2.authority_host does not parse as a URL"))?;
783            if parsed.scheme() != "https" {
784                bail!("adlsgen2.authority_host must use the https scheme");
785            }
786            if !parsed.username().is_empty() || parsed.password().is_some() {
787                bail!("adlsgen2.authority_host must not contain userinfo");
788            }
789            if parsed.query().is_some() || parsed.fragment().is_some() {
790                bail!("adlsgen2.authority_host must not contain a query or fragment");
791            }
792            if !matches!(parsed.path(), "" | "/") {
793                bail!("adlsgen2.authority_host must not contain a path component");
794            }
795        }
796        if let (Some(account), Some(key)) = (account, key) {
797            props.insert(ADLS_ACCOUNT_NAME.to_owned(), account);
798            props.insert(ADLS_ACCOUNT_KEY.to_owned(), key);
799        }
800        if let (Some(tenant), Some(client), Some(secret)) = (tenant, client, secret) {
801            props.insert(ADLS_TENANT_ID.to_owned(), tenant);
802            props.insert(ADLS_CLIENT_ID.to_owned(), client);
803            props.insert(ADLS_CLIENT_SECRET.to_owned(), secret);
804            props.insert(
805                ADLS_AUTHORITY_HOST.to_owned(),
806                authority
807                    .as_deref()
808                    .unwrap_or(ADLS_DEFAULT_AUTHORITY_HOST)
809                    .trim_end_matches('/')
810                    .to_owned(),
811            );
812        }
813        Ok(props)
814    }
815
816    fn build_native_glue_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
817        let mut iceberg_configs = HashMap::new();
818        // glue
819        if let Some(region) = self.glue_region() {
820            iceberg_configs.insert(AWS_REGION_NAME.to_owned(), region.to_owned());
821        }
822        if let Some(access_key) = self.glue_access_key() {
823            iceberg_configs.insert(AWS_ACCESS_KEY_ID.to_owned(), access_key.to_owned());
824        }
825        if let Some(secret_key) = self.glue_secret_key() {
826            iceberg_configs.insert(AWS_SECRET_ACCESS_KEY.to_owned(), secret_key.to_owned());
827        }
828        // s3
829        if let Some(region) = &self.s3_region {
830            iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
831        }
832        if let Some(endpoint) = &self.s3_endpoint {
833            iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
834        }
835        if let Some(access_key) = &self.s3_access_key {
836            iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
837        }
838        if let Some(secret_key) = &self.s3_secret_key {
839            iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
840        }
841        if let Some(role_arn) = &self.s3_iam_role_arn {
842            iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
843        }
844        iceberg_configs.insert(
845            S3_PATH_STYLE_ACCESS.to_owned(),
846            self.effective_s3_path_style_access().to_string(),
847        );
848        iceberg_configs.insert(
849            iceberg_catalog_glue::GLUE_CATALOG_PROP_WAREHOUSE.to_owned(),
850            self.warehouse_path
851                .clone()
852                .ok_or_else(|| anyhow!("`warehouse.path` must be set in glue catalog"))?,
853        );
854        if let Some(uri) = self.catalog_uri.as_deref() {
855            iceberg_configs.insert(
856                iceberg_catalog_glue::GLUE_CATALOG_PROP_URI.to_owned(),
857                uri.to_owned(),
858            );
859        }
860
861        Ok(CatalogBuildPlan::NativeGlue(iceberg_configs))
862    }
863
864    /// For both V1 and V2.
865    fn build_jni_catalog_configs(
866        &self,
867        catalog_impl: JniCatalogImpl,
868        java_catalog_props: &HashMap<String, String>,
869    ) -> ConnectorResult<(HashMap<String, String>, HashMap<String, String>)> {
870        let mut iceberg_configs = HashMap::new();
871        let enable_config_load = self.enable_config_load();
872        let file_io_props = {
873            let catalog_type = catalog_impl.catalog_type();
874
875            // Non-S3/Glue object-store backends only work with a REST catalog. This
876            // function is only invoked for catalog_type in {hive, snowflake, jdbc, rest,
877            // glue}, so the only accepted value here is "rest".
878            let require_rest = |backend: &str| -> ConnectorResult<()> {
879                if catalog_impl != JniCatalogImpl::Rest {
880                    bail!("{} unsupported in {} catalog", backend, catalog_type);
881                }
882                Ok(())
883            };
884
885            if let Some(region) = &self.s3_region {
886                // iceberg-rust
887                iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
888            }
889
890            if let Some(endpoint) = &self.s3_endpoint {
891                // iceberg-rust
892                iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
893            }
894
895            // iceberg-rust
896            if let Some(access_key) = &self.s3_access_key {
897                iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
898            }
899            if let Some(secret_key) = &self.s3_secret_key {
900                iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
901            }
902            if let Some(role_arn) = &self.s3_iam_role_arn {
903                iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
904            }
905            if let Some(gcs_credential) = &self.gcs_credential {
906                iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
907                require_rest("gcs")?;
908            }
909
910            if let (
911                Some(azblob_account_name),
912                Some(azblob_account_key),
913                Some(azblob_endpoint_url),
914            ) = (
915                &self.azblob_account_name,
916                &self.azblob_account_key,
917                &self.azblob_endpoint_url,
918            ) {
919                iceberg_configs.insert(AZBLOB_ACCOUNT_NAME.to_owned(), azblob_account_name.clone());
920                iceberg_configs.insert(AZBLOB_ACCOUNT_KEY.to_owned(), azblob_account_key.clone());
921                iceberg_configs.insert(AZBLOB_ENDPOINT.to_owned(), azblob_endpoint_url.clone());
922
923                require_rest("azblob")?;
924            }
925
926            let adls_props = self.adls_file_io_props()?;
927            if !adls_props.is_empty() {
928                require_rest("adlsgen2")?;
929                iceberg_configs.extend(adls_props);
930            }
931
932            match &self.warehouse_path {
933                Some(warehouse_path) => {
934                    let (bucket, _) = {
935                        let is_s3_tables = warehouse_path.starts_with("arn:aws:s3tables");
936                        // Lakehouse Iceberg REST catalog federation uses bq:// prefix for BigQuery-managed Iceberg tables.
937                        let is_bq_catalog_federation = warehouse_path.starts_with("bq://");
938                        let url = Url::parse(warehouse_path);
939                        if (url.is_err() || is_s3_tables || is_bq_catalog_federation)
940                            && catalog_impl == JniCatalogImpl::Rest
941                        {
942                            // If the warehouse path is not a valid URL, it could be:
943                            // - A warehouse name in REST catalog
944                            // - An S3 Tables path (arn:aws:s3tables:...)
945                            // - A Lakehouse path (bq://projects/...) for Google Cloud BigQuery integration
946                            // We allow these to pass through for REST catalogs.
947                            (None, None)
948                        } else {
949                            let url = url.with_context(|| {
950                                format!("Invalid warehouse path: {}", warehouse_path)
951                            })?;
952                            let bucket = url
953                                .host_str()
954                                .with_context(|| {
955                                    format!(
956                                        "Invalid s3 path: {}, bucket is missing",
957                                        warehouse_path
958                                    )
959                                })?
960                                .to_owned();
961                            let root = url.path().trim_start_matches('/').to_owned();
962                            (Some(bucket), Some(root))
963                        }
964                    };
965
966                    if let Some(bucket) = bucket {
967                        iceberg_configs.insert("iceberg.table.io.bucket".to_owned(), bucket);
968                    }
969                }
970                None => {
971                    if catalog_impl != JniCatalogImpl::Rest {
972                        bail!("`warehouse.path` must be set in {} catalog", catalog_type);
973                    }
974                }
975            }
976            iceberg_configs.insert(
977                S3_DISABLE_CONFIG_LOAD.to_owned(),
978                (!enable_config_load).to_string(),
979            );
980
981            iceberg_configs.insert(
982                GCS_DISABLE_CONFIG_LOAD.to_owned(),
983                (!enable_config_load).to_string(),
984            );
985
986            iceberg_configs.insert(
987                S3_PATH_STYLE_ACCESS.to_owned(),
988                self.effective_s3_path_style_access().to_string(),
989            );
990
991            iceberg_configs
992        };
993
994        // Prepare jni configs, for details please see https://iceberg.apache.org/docs/latest/aws/
995        let mut java_catalog_configs = HashMap::new();
996        {
997            if let Some(uri) = self.catalog_uri.as_deref() {
998                java_catalog_configs.insert("uri".to_owned(), uri.to_owned());
999            }
1000
1001            if let Some(warehouse_path) = &self.warehouse_path {
1002                java_catalog_configs.insert("warehouse".to_owned(), warehouse_path.clone());
1003            }
1004            java_catalog_configs.extend(java_catalog_props.clone());
1005
1006            // Set io-impl: use custom io-impl if provided, otherwise default to S3FileIO
1007            let io_impl = self
1008                .catalog_io_impl
1009                .clone()
1010                .unwrap_or_else(|| "org.apache.iceberg.aws.s3.S3FileIO".to_owned());
1011            java_catalog_configs.insert("io-impl".to_owned(), io_impl);
1012
1013            // suppress log of FileIO like: Unclosed FileIO instance created by...
1014            java_catalog_configs.insert("init-creation-stacktrace".to_owned(), "false".to_owned());
1015
1016            if let Some(region) = &self.s3_region {
1017                java_catalog_configs.insert("client.region".to_owned(), region.clone());
1018            }
1019            if let Some(endpoint) = &self.s3_endpoint {
1020                java_catalog_configs.insert("s3.endpoint".to_owned(), endpoint.clone());
1021            }
1022
1023            if let Some(access_key) = &self.s3_access_key {
1024                java_catalog_configs.insert("s3.access-key-id".to_owned(), access_key.clone());
1025            }
1026            if let Some(secret_key) = &self.s3_secret_key {
1027                java_catalog_configs.insert("s3.secret-access-key".to_owned(), secret_key.clone());
1028            }
1029
1030            if let Some(path_style_access) = &self.s3_path_style_access {
1031                java_catalog_configs.insert(
1032                    "s3.path-style-access".to_owned(),
1033                    path_style_access.to_string(),
1034                );
1035            }
1036
1037            let headers = self.headers()?;
1038            for (header_name, header_value) in headers {
1039                java_catalog_configs.insert(format!("header.{}", header_name), header_value);
1040            }
1041
1042            match catalog_impl {
1043                JniCatalogImpl::Rest => {
1044                    // Handle security type for REST catalog (Iceberg 1.10+)
1045                    if let Some(security) = &self.catalog_security {
1046                        match security.to_lowercase().as_str() {
1047                            "google" => {
1048                                // Google AuthManager (Iceberg 1.10+) - uses Google ADC
1049                                java_catalog_configs.insert(
1050                                    "rest.auth.type".to_owned(),
1051                                    "org.apache.iceberg.gcp.auth.GoogleAuthManager".to_owned(),
1052                                );
1053                                // Set GCP auth scopes if provided
1054                                if let Some(gcp_auth_scopes) = &self.gcp_auth_scopes {
1055                                    java_catalog_configs.insert(
1056                                        "gcp.auth.scopes".to_owned(),
1057                                        gcp_auth_scopes.clone(),
1058                                    );
1059                                }
1060                            }
1061                            "oauth2" => {
1062                                // Standard OAuth2 authentication
1063                                if let Some(credential) = &self.catalog_credential {
1064                                    java_catalog_configs
1065                                        .insert("credential".to_owned(), credential.clone());
1066                                }
1067                                if let Some(token) = &self.catalog_token {
1068                                    java_catalog_configs.insert("token".to_owned(), token.clone());
1069                                }
1070                                if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1071                                    java_catalog_configs.insert(
1072                                        "oauth2-server-uri".to_owned(),
1073                                        oauth2_server_uri.clone(),
1074                                    );
1075                                }
1076                                if let Some(scope) = &self.catalog_scope {
1077                                    java_catalog_configs.insert("scope".to_owned(), scope.clone());
1078                                }
1079                            }
1080                            "none" | "" => {
1081                                // No authentication
1082                            }
1083                            _ => {
1084                                tracing::warn!(
1085                                    "Unknown catalog.security value: {}. Supported values: none, oauth2, google",
1086                                    security
1087                                );
1088                            }
1089                        }
1090                    } else {
1091                        // Legacy behavior: use individual OAuth2 properties if security type not specified
1092                        if let Some(credential) = &self.catalog_credential {
1093                            java_catalog_configs
1094                                .insert("credential".to_owned(), credential.clone());
1095                        }
1096                        if let Some(token) = &self.catalog_token {
1097                            java_catalog_configs.insert("token".to_owned(), token.clone());
1098                        }
1099                        if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1100                            java_catalog_configs
1101                                .insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
1102                        }
1103                        if let Some(scope) = &self.catalog_scope {
1104                            java_catalog_configs.insert("scope".to_owned(), scope.clone());
1105                        }
1106                    }
1107                    if let Some(rest_signing_region) = &self.rest_signing_region {
1108                        java_catalog_configs.insert(
1109                            "rest.signing-region".to_owned(),
1110                            rest_signing_region.clone(),
1111                        );
1112                    }
1113                    if let Some(rest_signing_name) = &self.rest_signing_name {
1114                        java_catalog_configs
1115                            .insert("rest.signing-name".to_owned(), rest_signing_name.clone());
1116                    }
1117                    if self.rest_sigv4_enabled == Some(true) {
1118                        // Equivalent to the legacy `rest.sigv4-enabled=true`, which is
1119                        // deprecated since Iceberg 1.10 and warns on every catalog load.
1120                        java_catalog_configs
1121                            .insert("rest.auth.type".to_owned(), "sigv4".to_owned());
1122
1123                        if let Some(access_key) = &self.s3_access_key {
1124                            java_catalog_configs
1125                                .insert("rest.access-key-id".to_owned(), access_key.clone());
1126                        }
1127
1128                        if let Some(secret_key) = &self.s3_secret_key {
1129                            java_catalog_configs
1130                                .insert("rest.secret-access-key".to_owned(), secret_key.clone());
1131                        }
1132                    }
1133                    // Iceberg 1.10+ infers `rest.auth.type=oauth2` from `credential`/`token`
1134                    // when it is unset, warning on every catalog load. Set it explicitly.
1135                    if !java_catalog_configs.contains_key("rest.auth.type")
1136                        && (java_catalog_configs.contains_key("credential")
1137                            || java_catalog_configs.contains_key("token"))
1138                    {
1139                        java_catalog_configs
1140                            .insert("rest.auth.type".to_owned(), "oauth2".to_owned());
1141                    }
1142                }
1143                JniCatalogImpl::Glue => {
1144                    let glue_access_key = self.glue_access_key();
1145                    let glue_secret_key = self.glue_secret_key();
1146                    let has_glue_credentials =
1147                        glue_access_key.is_some() && glue_secret_key.is_some();
1148                    let should_configure_glue_provider = !enable_config_load
1149                        || has_glue_credentials
1150                        || self.glue_iam_role_arn.is_some();
1151
1152                    if should_configure_glue_provider {
1153                        java_catalog_configs.insert(
1154                            "client.credentials-provider".to_owned(),
1155                            "com.risingwave.connector.catalog.GlueCredentialProvider".to_owned(),
1156                        );
1157                        if let Some(region) = self.glue_region() {
1158                            java_catalog_configs.insert(
1159                                "client.credentials-provider.glue.region".to_owned(),
1160                                region.to_owned(),
1161                            );
1162                        }
1163                        if let Some(access_key) = glue_access_key {
1164                            java_catalog_configs.insert(
1165                                "client.credentials-provider.glue.access-key-id".to_owned(),
1166                                access_key.to_owned(),
1167                            );
1168                        }
1169                        if let Some(secret_key) = glue_secret_key {
1170                            java_catalog_configs.insert(
1171                                "client.credentials-provider.glue.secret-access-key".to_owned(),
1172                                secret_key.to_owned(),
1173                            );
1174                        }
1175                        if let Some(role_arn) = self.glue_iam_role_arn.as_deref() {
1176                            java_catalog_configs.insert(
1177                                "client.credentials-provider.glue.iam-role-arn".to_owned(),
1178                                role_arn.to_owned(),
1179                            );
1180                        }
1181                        if enable_config_load && !has_glue_credentials {
1182                            java_catalog_configs.insert(
1183                                "client.credentials-provider.glue.use-default-credential-chain"
1184                                    .to_owned(),
1185                                "true".to_owned(),
1186                            );
1187                        }
1188                    }
1189
1190                    if let Some(region) = self.glue_region() {
1191                        java_catalog_configs.insert("client.region".to_owned(), region.to_owned());
1192                    }
1193                    let glue_endpoint = self.glue_endpoint().map(str::to_owned).or_else(|| {
1194                        self.glue_region()
1195                            .map(|region| format!("https://glue.{}.amazonaws.com", region))
1196                    });
1197                    if let Some(endpoint) = glue_endpoint {
1198                        java_catalog_configs.insert("glue.endpoint".to_owned(), endpoint);
1199                    }
1200
1201                    if let Some(glue_id) = self.glue_id.as_deref() {
1202                        java_catalog_configs.insert("glue.id".to_owned(), glue_id.to_owned());
1203                    }
1204                    self.apply_java_s3_file_io_assume_role_configs(&mut java_catalog_configs);
1205                }
1206                JniCatalogImpl::Jdbc => {
1207                    self.apply_java_aws_client_assume_role_configs(&mut java_catalog_configs);
1208                }
1209                _ => {}
1210            }
1211        }
1212
1213        Ok((file_io_props, java_catalog_configs))
1214    }
1215
1216    fn apply_java_s3_file_io_assume_role_configs(
1217        &self,
1218        java_catalog_configs: &mut HashMap<String, String>,
1219    ) {
1220        if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1221            java_catalog_configs.insert(
1222                "s3.client-factory-impl".to_owned(),
1223                "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory".to_owned(),
1224            );
1225            java_catalog_configs.insert("s3.iam-role-arn".to_owned(), iam_role_arn.clone());
1226        }
1227    }
1228
1229    fn apply_java_aws_client_assume_role_configs(
1230        &self,
1231        java_catalog_configs: &mut HashMap<String, String>,
1232    ) {
1233        if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1234            java_catalog_configs.insert("client.assume-role.arn".to_owned(), iam_role_arn.clone());
1235            java_catalog_configs.insert(
1236                "client.factory".to_owned(),
1237                "org.apache.iceberg.aws.AssumeRoleAwsClientFactory".to_owned(),
1238            );
1239            if let Some(region) = &self.s3_region {
1240                java_catalog_configs.insert("client.assume-role.region".to_owned(), region.clone());
1241            }
1242        }
1243    }
1244}
1245
1246/// Get a globally shared object cache keyed by table UUID to avoid reuse after drop & recreate.
1247pub(crate) async fn shared_object_cache(
1248    init_object_cache: Arc<ObjectCache>,
1249    table_uuid: Uuid,
1250) -> Arc<ObjectCache> {
1251    static CACHE: LazyLock<MokaCache<Uuid, Arc<ObjectCache>>> = LazyLock::new(|| {
1252        MokaCache::builder()
1253            .max_capacity(SHARED_OBJECT_CACHE_MAX_TABLES)
1254            .build()
1255    });
1256
1257    CACHE
1258        .get_with(table_uuid, async { init_object_cache })
1259        .await
1260}
1261
1262pub async fn rebuild_table_with_shared_cache(table: Table) -> Table {
1263    let table_uuid = table.metadata().uuid();
1264    let init_object_cache = table.object_cache();
1265    let object_cache = shared_object_cache(init_object_cache, table_uuid).await;
1266    table.with_object_cache(object_cache)
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use std::collections::HashMap;
1272
1273    use super::*;
1274
1275    fn test_common(catalog_type: &str) -> IcebergCommon {
1276        IcebergCommon {
1277            catalog_type: Some(catalog_type.to_owned()),
1278            s3_region: Some("ap-southeast-2".to_owned()),
1279            s3_endpoint: None,
1280            s3_access_key: None,
1281            s3_secret_key: None,
1282            s3_iam_role_arn: None,
1283            glue_access_key: None,
1284            glue_secret_key: None,
1285            glue_iam_role_arn: None,
1286            glue_region: None,
1287            glue_endpoint: None,
1288            glue_id: None,
1289            gcs_credential: None,
1290            azblob_account_name: None,
1291            azblob_account_key: None,
1292            azblob_endpoint_url: None,
1293            adlsgen2_account_name: None,
1294            adlsgen2_account_key: None,
1295            adlsgen2_endpoint: None,
1296            adlsgen2_tenant_id: None,
1297            adlsgen2_client_id: None,
1298            adlsgen2_client_secret: None,
1299            adlsgen2_authority_host: None,
1300            warehouse_path: Some("s3://bucket/warehouse".to_owned()),
1301            catalog_name: None,
1302            catalog_uri: None,
1303            catalog_credential: None,
1304            catalog_token: None,
1305            catalog_oauth2_server_uri: None,
1306            catalog_scope: None,
1307            rest_signing_region: None,
1308            rest_signing_name: None,
1309            rest_sigv4_enabled: None,
1310            s3_path_style_access: None,
1311            enable_config_load: None,
1312            hosted_catalog: None,
1313            catalog_header: None,
1314            vended_credentials: None,
1315            catalog_security: None,
1316            gcp_auth_scopes: None,
1317            catalog_io_impl: None,
1318        }
1319    }
1320
1321    #[test]
1322    fn test_vended_rest_resolves_to_native_runtime_without_rewriting_catalog_type() {
1323        let common = IcebergCommon {
1324            vended_credentials: Some(true),
1325            ..test_common("rest")
1326        };
1327
1328        assert_eq!(common.catalog_type(), "rest");
1329        assert_eq!(
1330            common.resolve_catalog_kind().unwrap(),
1331            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust)
1332        );
1333    }
1334
1335    #[test]
1336    fn test_rest_without_vended_credentials_resolves_to_jni_runtime() {
1337        let common = test_common("rest");
1338
1339        assert_eq!(
1340            common.resolve_catalog_kind().unwrap(),
1341            IcebergCatalogKind::Rest(IcebergCatalogRuntime::JavaJni)
1342        );
1343    }
1344
1345    #[test]
1346    fn test_gcs_vended_rest_catalog_passes_google_authentication() {
1347        let common = IcebergCommon {
1348            catalog_uri: Some("https://catalog.example".to_owned()),
1349            warehouse_path: Some("gs://bucket/warehouse".to_owned()),
1350            catalog_security: Some("google".to_owned()),
1351            gcp_auth_scopes: Some("scope-a,scope-b".to_owned()),
1352            gcs_credential: Some("service-account-json".to_owned()),
1353            catalog_header: Some("x-goog-user-project=billing-project".to_owned()),
1354            vended_credentials: Some(true),
1355            ..test_common("rest")
1356        };
1357        assert_eq!(
1358            common.resolve_catalog_kind().unwrap(),
1359            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust)
1360        );
1361        let CatalogBuildPlan::NativeRest(props) = common.build_native_rest_catalog_props().unwrap()
1362        else {
1363            panic!("expected native REST catalog");
1364        };
1365        assert_eq!(props["rest.auth.type"], "google");
1366        assert_eq!(props["gcp.auth.scopes"], "scope-a,scope-b");
1367        assert_eq!(props[GCS_CREDENTIALS_JSON], "service-account-json");
1368        assert_eq!(props["warehouse"], "gs://bucket/warehouse");
1369        assert_eq!(
1370            props["header.X-Iceberg-Access-Delegation"],
1371            "vended-credentials"
1372        );
1373        assert_eq!(props["header.x-goog-user-project"], "billing-project");
1374        assert!(!props.contains_key("io-impl"));
1375    }
1376
1377    #[test]
1378    fn test_native_google_catalog_supports_adc_without_storage_credentials() {
1379        let common = IcebergCommon {
1380            catalog_uri: Some("https://catalog.example".to_owned()),
1381            catalog_security: Some("google".to_owned()),
1382            vended_credentials: Some(true),
1383            ..test_common("rest_rust")
1384        };
1385        let CatalogBuildPlan::NativeRest(props) = common.build_native_rest_catalog_props().unwrap()
1386        else {
1387            panic!("expected native REST catalog");
1388        };
1389        assert_eq!(props["rest.auth.type"], "google");
1390        assert!(!props.contains_key(GCS_CREDENTIALS_JSON));
1391        assert!(!props.contains_key("gcp.auth.scopes"));
1392    }
1393
1394    #[test]
1395    fn test_native_google_catalog_rejects_adc_when_default_credentials_disabled() {
1396        for security in ["google", "GoOgLe"] {
1397            for (credential, token) in [
1398                (None, None),
1399                (Some("client:secret"), None),
1400                (None, Some("catalog-token")),
1401                (Some("client:secret"), Some("catalog-token")),
1402            ] {
1403                let common = IcebergCommon {
1404                    catalog_security: Some(security.to_owned()),
1405                    catalog_credential: credential.map(str::to_owned),
1406                    catalog_token: token.map(str::to_owned),
1407                    ..test_common("rest_rust")
1408                };
1409                assert_eq!(
1410                    common
1411                        .validate_native_rest_catalog_auth(true)
1412                        .unwrap_err()
1413                        .to_string(),
1414                    "`catalog.security=google` requires `gcs.credential` when `DISABLE_DEFAULT_CREDENTIAL=true`"
1415                );
1416                common.validate_native_rest_catalog_auth(false).unwrap();
1417
1418                let common = IcebergCommon {
1419                    gcs_credential: Some("service-account-json".to_owned()),
1420                    ..common
1421                };
1422                common.validate_native_rest_catalog_auth(true).unwrap();
1423            }
1424        }
1425    }
1426
1427    #[test]
1428    fn test_native_non_google_catalog_auth_does_not_require_gcs_credentials() {
1429        for security in [None, Some("none"), Some("oauth2")] {
1430            let common = IcebergCommon {
1431                catalog_security: security.map(str::to_owned),
1432                ..test_common("rest_rust")
1433            };
1434            common.validate_native_rest_catalog_auth(true).unwrap();
1435        }
1436    }
1437
1438    #[test]
1439    fn test_s3_path_style_access_preserves_existing_default() {
1440        let common = test_common("storage");
1441        assert!(common.effective_s3_path_style_access());
1442
1443        let common = IcebergCommon {
1444            s3_path_style_access: Some(false),
1445            ..common
1446        };
1447        assert!(!common.effective_s3_path_style_access());
1448    }
1449
1450    #[test]
1451    fn test_mock_v3_resolves_to_mock_catalog_for_simulation_tests() {
1452        let common = test_common("mock_v3");
1453
1454        assert_eq!(
1455            common.resolve_catalog_kind().unwrap(),
1456            IcebergCatalogKind::Mock
1457        );
1458    }
1459
1460    #[test]
1461    fn test_extract_java_catalog_props_keeps_wire_options_flat() {
1462        let options = HashMap::from([
1463            ("catalog.type".to_owned(), "rest".to_owned()),
1464            ("catalog.uri".to_owned(), "http://localhost:8181".to_owned()),
1465            ("catalog.name".to_owned(), "demo".to_owned()),
1466            ("catalog.header".to_owned(), "x=y".to_owned()),
1467            (
1468                "catalog.rest.signing_region".to_owned(),
1469                "us-east-1".to_owned(),
1470            ),
1471            ("catalog.jdbc.user".to_owned(), "rw".to_owned()),
1472        ]);
1473
1474        let java_props = iceberg_java_catalog_props_from_options(
1475            options
1476                .iter()
1477                .map(|(key, value)| (key.as_str(), value.as_str())),
1478        );
1479
1480        assert_eq!(java_props.get("rest.signing_region").unwrap(), "us-east-1");
1481        assert_eq!(java_props.get("jdbc.user").unwrap(), "rw");
1482        assert!(!java_props.contains_key("type"));
1483        assert!(!java_props.contains_key("uri"));
1484        assert!(!java_props.contains_key("name"));
1485        assert!(!java_props.contains_key("header"));
1486    }
1487
1488    #[test]
1489    fn test_glue_jni_catalog_uses_s3_assume_role_for_file_io() {
1490        let common = IcebergCommon {
1491            s3_iam_role_arn: Some("arn:aws:iam::123456789012:role/risingwave-s3".to_owned()),
1492            ..test_common("glue")
1493        };
1494
1495        let (_, java_catalog_configs) = common
1496            .build_jni_catalog_configs(JniCatalogImpl::Glue, &HashMap::new())
1497            .unwrap();
1498
1499        assert_eq!(
1500            java_catalog_configs.get("s3.client-factory-impl").unwrap(),
1501            "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory"
1502        );
1503        assert_eq!(
1504            java_catalog_configs.get("s3.iam-role-arn").unwrap(),
1505            "arn:aws:iam::123456789012:role/risingwave-s3"
1506        );
1507        assert!(!java_catalog_configs.contains_key("client.factory"));
1508    }
1509
1510    #[test]
1511    fn test_rest_jni_catalog_sets_auth_type_explicitly() {
1512        let build = |common: IcebergCommon, props: &[(&str, &str)]| {
1513            let props = props
1514                .iter()
1515                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1516                .collect();
1517            common
1518                .build_jni_catalog_configs(JniCatalogImpl::Rest, &props)
1519                .unwrap()
1520                .1
1521        };
1522        let oauth2 = IcebergCommon {
1523            catalog_credential: Some("client-id:client-secret".to_owned()),
1524            rest_sigv4_enabled: Some(false),
1525            ..test_common("rest")
1526        };
1527
1528        let configs = build(oauth2.clone(), &[]);
1529        assert_eq!(configs.get("rest.auth.type").unwrap(), "oauth2");
1530        assert!(!configs.contains_key("rest.sigv4-enabled"));
1531
1532        let configs = build(oauth2, &[("rest.auth.type", "basic")]);
1533        assert_eq!(configs.get("rest.auth.type").unwrap(), "basic");
1534
1535        let sigv4 = IcebergCommon {
1536            catalog_token: Some("token".to_owned()),
1537            rest_sigv4_enabled: Some(true),
1538            ..test_common("rest")
1539        };
1540        let configs = build(sigv4, &[]);
1541        assert_eq!(configs.get("rest.auth.type").unwrap(), "sigv4");
1542        assert!(!configs.contains_key("rest.sigv4-enabled"));
1543    }
1544
1545    #[test]
1546    fn test_adlsgen2_service_principal_populates_file_io_configs_with_default_authority_host() {
1547        let common = test_adlsgen2_service_principal_common(None);
1548
1549        let (file_io_props, _) = common
1550            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1551            .unwrap();
1552
1553        assert_eq!(file_io_props.get(ADLS_TENANT_ID).unwrap(), "tenant-uuid");
1554        assert_eq!(file_io_props.get(ADLS_CLIENT_ID).unwrap(), "client-uuid");
1555        assert_eq!(
1556            file_io_props.get(ADLS_CLIENT_SECRET).unwrap(),
1557            "secret-value"
1558        );
1559        assert_eq!(
1560            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1561            ADLS_DEFAULT_AUTHORITY_HOST
1562        );
1563    }
1564
1565    fn test_adlsgen2_service_principal_common(authority_host: Option<&str>) -> IcebergCommon {
1566        IcebergCommon {
1567            adlsgen2_account_name: Some("acct".to_owned()),
1568            adlsgen2_tenant_id: Some("tenant-uuid".to_owned()),
1569            adlsgen2_client_id: Some("client-uuid".to_owned()),
1570            adlsgen2_client_secret: Some("secret-value".to_owned()),
1571            adlsgen2_authority_host: authority_host.map(str::to_owned),
1572            catalog_uri: Some("http://localhost:8181".to_owned()),
1573            warehouse_path: Some("abfss://wh@acct.dfs.core.windows.net/wh".to_owned()),
1574            ..test_common("rest")
1575        }
1576    }
1577
1578    #[test]
1579    fn test_adlsgen2_service_principal_authority_host_override_is_respected() {
1580        let common =
1581            test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us"));
1582
1583        let (file_io_props, _) = common
1584            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1585            .unwrap();
1586
1587        assert_eq!(
1588            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1589            "https://login.microsoftonline.us"
1590        );
1591    }
1592
1593    #[test]
1594    fn test_adlsgen2_authority_host_rejects_non_bare_https_origins() {
1595        let cases = [
1596            ("not a url", "does not parse as a URL"),
1597            (
1598                "http://login.microsoftonline.com",
1599                "must use the https scheme",
1600            ),
1601            (
1602                "https://user:pass@login.microsoftonline.com",
1603                "must not contain userinfo",
1604            ),
1605            (
1606                "https://login.microsoftonline.com?bar=baz",
1607                "must not contain a query or fragment",
1608            ),
1609            (
1610                "https://login.microsoftonline.com#frag",
1611                "must not contain a query or fragment",
1612            ),
1613            (
1614                "https://login.microsoftonline.com/foo",
1615                "must not contain a path component",
1616            ),
1617        ];
1618        for (authority_host, expected_error) in cases {
1619            let common = test_adlsgen2_service_principal_common(Some(authority_host));
1620            let err = common
1621                .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1622                .unwrap_err();
1623            assert!(
1624                format!("{:#}", err).contains(expected_error),
1625                "authority_host {authority_host:?}: expected error containing {expected_error:?}, got: {err:#}"
1626            );
1627        }
1628    }
1629
1630    #[test]
1631    fn test_adlsgen2_authority_host_trailing_slash_is_normalized() {
1632        let common =
1633            test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us/"));
1634
1635        let (file_io_props, _) = common
1636            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1637            .unwrap();
1638
1639        assert_eq!(
1640            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1641            "https://login.microsoftonline.us"
1642        );
1643    }
1644
1645    #[test]
1646    fn test_adlsgen2_rejects_mixing_shared_key_and_service_principal() {
1647        let common = IcebergCommon {
1648            adlsgen2_account_key: Some("shared-key".to_owned()),
1649            ..test_adlsgen2_service_principal_common(None)
1650        };
1651
1652        let err = common
1653            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1654            .unwrap_err();
1655        assert!(
1656            format!("{:#}", err).contains("exactly one auth mode"),
1657            "expected mutual-exclusion error, got: {err:#}"
1658        );
1659    }
1660
1661    #[test]
1662    fn test_adlsgen2_rejects_partial_service_principal_config() {
1663        let common = IcebergCommon {
1664            adlsgen2_client_secret: None,
1665            ..test_adlsgen2_service_principal_common(None)
1666        };
1667
1668        let err = common
1669            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1670            .unwrap_err();
1671        assert!(
1672            format!("{:#}", err).contains("requires all three"),
1673            "expected partial-config error, got: {err:#}"
1674        );
1675    }
1676
1677    #[test]
1678    fn test_iceberg_table_identifier_validation() {
1679        let valid_identifier = IcebergTableIdentifier {
1680            database_name: Some("valid_db".to_owned()),
1681            table_name: "test_table".to_owned(),
1682        };
1683        assert!(valid_identifier.validate().is_ok());
1684
1685        let valid_underscore = IcebergTableIdentifier {
1686            database_name: Some("valid_db_name".to_owned()),
1687            table_name: "test_table".to_owned(),
1688        };
1689        assert!(valid_underscore.validate().is_ok());
1690
1691        let no_database = IcebergTableIdentifier {
1692            database_name: None,
1693            table_name: "test_table".to_owned(),
1694        };
1695        assert!(no_database.validate().is_ok());
1696
1697        let empty_part = IcebergTableIdentifier {
1698            database_name: Some("a..b".to_owned()),
1699            table_name: "test_table".to_owned(),
1700        };
1701        let result = empty_part.validate();
1702        assert!(result.is_err());
1703        assert!(
1704            result
1705                .unwrap_err()
1706                .to_string()
1707                .contains("identifier parts must not be empty")
1708        );
1709
1710        let leading_dot = IcebergTableIdentifier {
1711            database_name: None,
1712            table_name: ".test_table".to_owned(),
1713        };
1714        let result = leading_dot.validate();
1715        assert!(result.is_err());
1716        assert!(
1717            result
1718                .unwrap_err()
1719                .to_string()
1720                .contains("identifier parts must not be empty")
1721        );
1722    }
1723
1724    #[test]
1725    fn test_iceberg_table_identifier_dots_as_namespace_separators() {
1726        let table_ident = IcebergTableIdentifier {
1727            database_name: Some("general.zia.stats".to_owned()),
1728            table_name: "tagged_security_transactions".to_owned(),
1729        }
1730        .to_table_ident()
1731        .unwrap();
1732        let namespace: Vec<_> = table_ident
1733            .namespace()
1734            .as_ref()
1735            .iter()
1736            .map(String::as_str)
1737            .collect();
1738        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1739        assert_eq!(table_ident.name(), "tagged_security_transactions");
1740
1741        let table_ident = IcebergTableIdentifier {
1742            database_name: Some("general".to_owned()),
1743            table_name: "zia.stats.tagged_security_transactions".to_owned(),
1744        }
1745        .to_table_ident()
1746        .unwrap();
1747        let namespace: Vec<_> = table_ident
1748            .namespace()
1749            .as_ref()
1750            .iter()
1751            .map(String::as_str)
1752            .collect();
1753        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1754        assert_eq!(table_ident.name(), "tagged_security_transactions");
1755
1756        let table_ident = IcebergTableIdentifier {
1757            database_name: None,
1758            table_name: "general.zia.stats.tagged_security_transactions".to_owned(),
1759        }
1760        .to_table_ident()
1761        .unwrap();
1762        let namespace: Vec<_> = table_ident
1763            .namespace()
1764            .as_ref()
1765            .iter()
1766            .map(String::as_str)
1767            .collect();
1768        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1769        assert_eq!(table_ident.name(), "tagged_security_transactions");
1770    }
1771}