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`, uses Iceberg's `GoogleAuthManager` (requires Iceberg 1.10+)
193    /// for authentication using Google Application Default Credentials (ADC).
194    #[serde(rename = "catalog.security")]
195    pub catalog_security: Option<String>,
196
197    /// OAuth-based scopes for Google authentication.
198    /// Comma-separated list of OAuth-based scopes to request.
199    /// Only applicable when `catalog.security` is set to `google`.
200    /// Default: <https://www.googleapis.com/auth/cloud-platform>
201    #[serde(rename = "gcp.auth.scopes")]
202    pub gcp_auth_scopes: Option<String>,
203
204    /// Custom `FileIO` implementation class for the Iceberg catalog.
205    /// Allows specifying a custom `FileIO` implementation instead of the default.
206    /// Examples:
207    /// - `org.apache.iceberg.aws.s3.S3FileIO` for Amazon S3 (default)
208    /// - `org.apache.iceberg.gcp.gcs.GCSFileIO` for Google Cloud Storage
209    /// - `org.apache.iceberg.azure.adlsv2.ADLSFileIO` for Azure Data Lake Storage Gen2
210    /// Google Cloud Lakehouse Iceberg REST catalogs with credential vending require
211    /// `org.apache.iceberg.gcp.gcs.GCSFileIO`.
212    #[serde(rename = "catalog.io_impl")]
213    pub catalog_io_impl: Option<String>,
214}
215
216// Matches iceberg::io::object_cache default size (32MB).
217// TODO: change it after object cache get refactored.
218const DEFAULT_OBJECT_CACHE_SIZE_BYTES: u64 = 32 * 1024 * 1024;
219const SHARED_OBJECT_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
220const SHARED_OBJECT_CACHE_MAX_TABLES: u64 =
221    SHARED_OBJECT_CACHE_BUDGET_BYTES / DEFAULT_OBJECT_CACHE_SIZE_BYTES;
222
223/// Default Microsoft Entra (AAD) authority host for public Azure. Sovereign-cloud
224/// users override via `adlsgen2.authority_host`.
225const ADLS_DEFAULT_AUTHORITY_HOST: &str = "https://login.microsoftonline.com";
226
227impl EnforceSecret for IcebergCommon {
228    const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
229        "s3.access.key",
230        "s3.secret.key",
231        "gcs.credential",
232        "catalog.credential",
233        "catalog.token",
234        "catalog.oauth2_server_uri",
235        "adlsgen2.account_key",
236        "adlsgen2.client_secret",
237        "glue.access.key",
238        "glue.secret.key",
239    };
240}
241
242#[serde_as]
243#[derive(Debug, Clone, PartialEq, Eq, Deserialize, WithOptions)]
244#[serde(deny_unknown_fields)]
245pub struct IcebergTableIdentifier {
246    #[serde(rename = "database.name")]
247    pub database_name: Option<String>,
248    /// Table name or namespace-qualified table name. Dots are treated as
249    /// Iceberg namespace separators.
250    #[serde(rename = "table.name")]
251    pub table_name: String,
252}
253
254impl IcebergTableIdentifier {
255    pub fn database_name(&self) -> Option<&str> {
256        self.database_name.as_deref()
257    }
258
259    pub fn table_name(&self) -> &str {
260        &self.table_name
261    }
262
263    fn identifier_parts(&self) -> ConnectorResult<Vec<&str>> {
264        let mut parts = Vec::new();
265        if let Some(database_name) = &self.database_name {
266            parts.extend(database_name.split('.'));
267        }
268        parts.extend(self.table_name.split('.'));
269
270        if parts.iter().any(|part| part.is_empty()) {
271            bail!(
272                "Invalid iceberg table identifier '{}': identifier parts must not be empty",
273                self.full_identifier()
274            );
275        }
276
277        Ok(parts)
278    }
279
280    fn full_identifier(&self) -> String {
281        match &self.database_name {
282            Some(database_name) => format!("{}.{}", database_name, self.table_name),
283            None => self.table_name.clone(),
284        }
285    }
286
287    pub fn to_table_ident(&self) -> ConnectorResult<TableIdent> {
288        let ret = TableIdent::from_strs(self.identifier_parts()?);
289
290        Ok(ret.context("Failed to create table identifier")?)
291    }
292
293    pub fn validate(&self) -> ConnectorResult<()> {
294        self.identifier_parts().map(|_| ())
295    }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum IcebergCatalogRuntime {
300    NativeRust,
301    JavaJni,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum IcebergCatalogKind {
306    Storage,
307    Rest(IcebergCatalogRuntime),
308    Glue(IcebergCatalogRuntime),
309    Hive,
310    Jdbc,
311    Snowflake,
312    Mock,
313}
314
315impl IcebergCatalogKind {
316    fn resolve(common: &IcebergCommon) -> ConnectorResult<Self> {
317        let catalog_type = common.catalog_type();
318        let kind = match catalog_type {
319            "storage" => Self::Storage,
320            "rest" if common.vended_credentials() => Self::Rest(IcebergCatalogRuntime::NativeRust),
321            "rest" => Self::Rest(IcebergCatalogRuntime::JavaJni),
322            "rest_rust" => Self::Rest(IcebergCatalogRuntime::NativeRust),
323            "glue" => Self::Glue(IcebergCatalogRuntime::JavaJni),
324            "glue_rust" => Self::Glue(IcebergCatalogRuntime::NativeRust),
325            "hive" => Self::Hive,
326            "jdbc" => Self::Jdbc,
327            "snowflake" => Self::Snowflake,
328            #[cfg(any(test, madsim))]
329            "mock_v3" => Self::Mock,
330            "mock" => Self::Mock,
331            _ => {
332                bail!(
333                    "Unsupported catalog type: {}, only support `storage`, `rest`, `hive`, `jdbc`, `glue`, `snowflake`",
334                    catalog_type
335                )
336            }
337        };
338        Ok(kind)
339    }
340
341    pub fn is_rest(self) -> bool {
342        matches!(self, Self::Rest(_))
343    }
344
345    fn jni_impl(self) -> Option<JniCatalogImpl> {
346        match self {
347            Self::Rest(IcebergCatalogRuntime::JavaJni) => Some(JniCatalogImpl::Rest),
348            Self::Glue(IcebergCatalogRuntime::JavaJni) => Some(JniCatalogImpl::Glue),
349            Self::Hive => Some(JniCatalogImpl::Hive),
350            Self::Jdbc => Some(JniCatalogImpl::Jdbc),
351            Self::Snowflake => Some(JniCatalogImpl::Snowflake),
352            _ => None,
353        }
354    }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358enum JniCatalogImpl {
359    Hive,
360    Jdbc,
361    Snowflake,
362    Rest,
363    Glue,
364}
365
366impl JniCatalogImpl {
367    fn catalog_type(self) -> &'static str {
368        match self {
369            Self::Hive => "hive",
370            Self::Jdbc => "jdbc",
371            Self::Snowflake => "snowflake",
372            Self::Rest => "rest",
373            Self::Glue => "glue",
374        }
375    }
376
377    fn class_name(self) -> &'static str {
378        match self {
379            Self::Hive => "org.apache.iceberg.hive.HiveCatalog",
380            Self::Jdbc => "org.apache.iceberg.jdbc.JdbcCatalog",
381            Self::Snowflake => "org.apache.iceberg.snowflake.SnowflakeCatalog",
382            Self::Rest => "org.apache.iceberg.rest.RESTCatalog",
383            Self::Glue => "org.apache.iceberg.aws.glue.GlueCatalog",
384        }
385    }
386}
387
388enum CatalogBuildPlan {
389    Storage(StorageCatalogConfig),
390    NativeRest(HashMap<String, String>),
391    NativeGlue(HashMap<String, String>),
392    Jni {
393        file_io_props: HashMap<String, String>,
394        catalog_name: String,
395        catalog_impl: JniCatalogImpl,
396        java_catalog_props: HashMap<String, String>,
397    },
398    Mock,
399}
400
401pub struct ResolvedIcebergCatalogConfig<'a> {
402    common: &'a IcebergCommon,
403    kind: IcebergCatalogKind,
404    java_catalog_props: HashMap<String, String>,
405}
406
407impl<'a> ResolvedIcebergCatalogConfig<'a> {
408    pub fn kind(&self) -> IcebergCatalogKind {
409        self.kind
410    }
411
412    fn build_plan(&self) -> ConnectorResult<CatalogBuildPlan> {
413        match self.kind {
414            IcebergCatalogKind::Storage => self.common.build_storage_catalog_config(),
415            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust) => {
416                self.common.build_native_rest_catalog_props()
417            }
418            IcebergCatalogKind::Glue(IcebergCatalogRuntime::NativeRust) => {
419                self.common.build_native_glue_catalog_props()
420            }
421            IcebergCatalogKind::Mock => Ok(CatalogBuildPlan::Mock),
422            kind => {
423                let catalog_impl = kind.jni_impl().expect("java catalog kind has JNI impl");
424                let (file_io_props, java_catalog_props) = self
425                    .common
426                    .build_jni_catalog_configs(catalog_impl, &self.java_catalog_props)?;
427                Ok(CatalogBuildPlan::Jni {
428                    file_io_props,
429                    catalog_name: self.common.catalog_name(),
430                    catalog_impl,
431                    java_catalog_props,
432                })
433            }
434        }
435    }
436
437    pub async fn create_catalog(&self) -> ConnectorResult<Arc<dyn Catalog>> {
438        match self.build_plan()? {
439            CatalogBuildPlan::Storage(config) => {
440                let catalog = storage_catalog::StorageCatalog::new(config)?;
441                Ok(Arc::new(catalog))
442            }
443            CatalogBuildPlan::NativeRest(iceberg_configs) => {
444                let catalog = iceberg_catalog_rest::RestCatalogBuilder::default()
445                    .with_storage_factory(Arc::new(OpenDalResolvingStorageFactory::new()))
446                    .load("rest", iceberg_configs)
447                    .await
448                    .map_err(|e| anyhow!(IcebergError::from(e)))?;
449                Ok(Arc::new(catalog))
450            }
451            CatalogBuildPlan::NativeGlue(iceberg_configs) => {
452                let catalog = iceberg_catalog_glue::GlueCatalogBuilder::default()
453                    .load("glue", iceberg_configs)
454                    .await
455                    .map_err(|e| anyhow!(IcebergError::from(e)))?;
456                Ok(Arc::new(catalog))
457            }
458            CatalogBuildPlan::Jni {
459                file_io_props,
460                catalog_name,
461                catalog_impl,
462                java_catalog_props,
463            } => {
464                jni_catalog::JniCatalog::build_catalog(
465                    file_io_props,
466                    catalog_name,
467                    catalog_impl.class_name(),
468                    java_catalog_props,
469                )
470                .await
471            }
472            CatalogBuildPlan::Mock => Ok(Arc::new(mock_catalog::MockCatalog {})),
473        }
474    }
475
476    pub async fn load_table(&self, table: &IcebergTableIdentifier) -> ConnectorResult<Table> {
477        let catalog = self
478            .create_catalog()
479            .await
480            .context("Unable to load iceberg catalog")?;
481
482        let table_id = table
483            .to_table_ident()
484            .context("Unable to parse table name")?;
485
486        let table = catalog.load_table(&table_id).await?;
487        Ok(rebuild_table_with_shared_cache(table).await)
488    }
489}
490
491pub fn iceberg_java_catalog_props_from_options<'a>(
492    options: impl Iterator<Item = (&'a str, &'a str)>,
493) -> HashMap<String, String> {
494    options
495        .filter(|(k, _v)| {
496            k.starts_with("catalog.")
497                && k != &"catalog.uri"
498                && k != &"catalog.type"
499                && k != &"catalog.name"
500                && k != &"catalog.header"
501        })
502        .map(|(k, v)| (k[8..].to_owned(), v.to_owned()))
503        .collect()
504}
505
506impl IcebergCommon {
507    pub fn catalog_type(&self) -> &str {
508        self.catalog_type.as_deref().unwrap_or("storage")
509    }
510
511    pub fn vended_credentials(&self) -> bool {
512        self.vended_credentials.unwrap_or(false)
513    }
514
515    pub fn resolve_catalog_kind(&self) -> ConnectorResult<IcebergCatalogKind> {
516        IcebergCatalogKind::resolve(self)
517    }
518
519    pub fn is_rest_catalog(&self) -> ConnectorResult<bool> {
520        Ok(self.resolve_catalog_kind()?.is_rest())
521    }
522
523    pub fn resolve_catalog_config(
524        &self,
525        java_catalog_props: HashMap<String, String>,
526    ) -> ConnectorResult<ResolvedIcebergCatalogConfig<'_>> {
527        Ok(ResolvedIcebergCatalogConfig {
528            common: self,
529            kind: self.resolve_catalog_kind()?,
530            java_catalog_props,
531        })
532    }
533
534    fn glue_access_key(&self) -> Option<&str> {
535        self.glue_access_key
536            .as_deref()
537            .or(self.s3_access_key.as_deref())
538    }
539
540    fn glue_secret_key(&self) -> Option<&str> {
541        self.glue_secret_key
542            .as_deref()
543            .or(self.s3_secret_key.as_deref())
544    }
545
546    fn glue_region(&self) -> Option<&str> {
547        self.glue_region.as_deref().or(self.s3_region.as_deref())
548    }
549
550    fn glue_endpoint(&self) -> Option<&str> {
551        self.glue_endpoint
552            .as_deref()
553            .or_else(|| match self.resolve_catalog_kind() {
554                Ok(IcebergCatalogKind::Glue(IcebergCatalogRuntime::JavaJni)) => {
555                    self.catalog_uri.as_deref()
556                }
557                _ => None,
558            })
559    }
560
561    pub fn catalog_name(&self) -> String {
562        self.catalog_name
563            .as_ref()
564            .cloned()
565            .unwrap_or_else(|| "risingwave".to_owned())
566    }
567
568    pub fn headers(&self) -> ConnectorResult<HashMap<String, String>> {
569        let mut headers = HashMap::new();
570        let user_agent = match Deployment::current() {
571            Deployment::Ci => "RisingWave(CI)".to_owned(),
572            Deployment::Cloud => "RisingWave(Cloud)".to_owned(),
573            Deployment::Other => "RisingWave(OSS)".to_owned(),
574        };
575        if self.vended_credentials() {
576            headers.insert(
577                "X-Iceberg-Access-Delegation".to_owned(),
578                "vended-credentials".to_owned(),
579            );
580        }
581        headers.insert("User-Agent".to_owned(), user_agent);
582        if let Some(header) = &self.catalog_header {
583            for pair in header.split(';') {
584                let mut parts = pair.split('=');
585                if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
586                    headers.insert(key.to_owned(), value.to_owned());
587                } else {
588                    bail!("Invalid header format: {}", pair);
589                }
590            }
591        }
592        Ok(headers)
593    }
594
595    pub fn enable_config_load(&self) -> bool {
596        // If the env var is set to true, we disable the default config load. (Cloud environment)
597        if env_var_is_true(DISABLE_DEFAULT_CREDENTIAL) {
598            if matches!(self.enable_config_load, Some(true)) {
599                tracing::warn!(
600                    "`enable_config_load` can't be enabled in SaaS environment, the behavior might be unexpected"
601                );
602            }
603            return false;
604        }
605        self.enable_config_load.unwrap_or(false)
606    }
607
608    fn effective_s3_path_style_access(&self) -> bool {
609        // RisingWave historically inherited OpenDAL's path-style default. Iceberg now
610        // defaults to virtual-host style, so preserve existing connector behavior unless
611        // the user explicitly opts into virtual-host style with `false`.
612        self.s3_path_style_access.unwrap_or(true)
613    }
614
615    fn build_storage_catalog_config(&self) -> ConnectorResult<CatalogBuildPlan> {
616        let warehouse = self
617            .warehouse_path
618            .clone()
619            .ok_or_else(|| anyhow!("`warehouse.path` must be set in storage catalog"))?;
620        let url = Url::parse(warehouse.as_ref())
621            .map_err(|_| anyhow!("Invalid warehouse path: {}", warehouse))?;
622
623        let config = match url.scheme() {
624            "s3" | "s3a" => StorageCatalogConfig::S3(
625                storage_catalog::StorageCatalogS3Config::builder()
626                    .warehouse(warehouse)
627                    .access_key(self.s3_access_key.clone())
628                    .secret_key(self.s3_secret_key.clone())
629                    .region(self.s3_region.clone())
630                    .endpoint(self.s3_endpoint.clone())
631                    .path_style_access(Some(self.effective_s3_path_style_access()))
632                    .enable_config_load(Some(self.enable_config_load()))
633                    .build(),
634            ),
635            "gs" | "gcs" => StorageCatalogConfig::Gcs(
636                storage_catalog::StorageCatalogGcsConfig::builder()
637                    .warehouse(warehouse)
638                    .credential(self.gcs_credential.clone())
639                    .enable_config_load(Some(self.enable_config_load()))
640                    .build(),
641            ),
642            "azblob" => StorageCatalogConfig::Azblob(
643                storage_catalog::StorageCatalogAzblobConfig::builder()
644                    .warehouse(warehouse)
645                    .account_name(self.azblob_account_name.clone())
646                    .account_key(self.azblob_account_key.clone())
647                    .endpoint(self.azblob_endpoint_url.clone())
648                    .build(),
649            ),
650            scheme => bail!("Unsupported warehouse scheme: {}", scheme),
651        };
652
653        Ok(CatalogBuildPlan::Storage(config))
654    }
655
656    fn build_native_rest_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
657        let mut iceberg_configs = HashMap::new();
658
659        // check gcs credential or s3 access key and secret key
660        if let Some(gcs_credential) = &self.gcs_credential {
661            iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
662        } else {
663            if let Some(region) = &self.s3_region {
664                iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
665            }
666            if let Some(endpoint) = &self.s3_endpoint {
667                iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
668            }
669            if let Some(access_key) = &self.s3_access_key {
670                iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
671            }
672            if let Some(secret_key) = &self.s3_secret_key {
673                iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
674            }
675            if let Some(path_style_access) = &self.s3_path_style_access {
676                iceberg_configs.insert(
677                    S3_PATH_STYLE_ACCESS.to_owned(),
678                    path_style_access.to_string(),
679                );
680            }
681        };
682
683        if let Some(credential) = &self.catalog_credential {
684            iceberg_configs.insert("credential".to_owned(), credential.clone());
685        }
686        if let Some(token) = &self.catalog_token {
687            iceberg_configs.insert("token".to_owned(), token.clone());
688        }
689        if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
690            iceberg_configs.insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
691        }
692        if let Some(scope) = &self.catalog_scope {
693            iceberg_configs.insert("scope".to_owned(), scope.clone());
694        }
695
696        let headers = self.headers()?;
697        for (header_name, header_value) in headers {
698            iceberg_configs.insert(format!("header.{}", header_name), header_value);
699        }
700
701        iceberg_configs.insert(
702            iceberg_catalog_rest::REST_CATALOG_PROP_URI.to_owned(),
703            self.catalog_uri
704                .clone()
705                .with_context(|| "`catalog.uri` must be set in rest catalog".to_owned())?,
706        );
707        if let Some(warehouse_path) = &self.warehouse_path {
708            iceberg_configs.insert(
709                iceberg_catalog_rest::REST_CATALOG_PROP_WAREHOUSE.to_owned(),
710                warehouse_path.clone(),
711            );
712        }
713
714        Ok(CatalogBuildPlan::NativeRest(iceberg_configs))
715    }
716
717    fn build_native_glue_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
718        let mut iceberg_configs = HashMap::new();
719        // glue
720        if let Some(region) = self.glue_region() {
721            iceberg_configs.insert(AWS_REGION_NAME.to_owned(), region.to_owned());
722        }
723        if let Some(access_key) = self.glue_access_key() {
724            iceberg_configs.insert(AWS_ACCESS_KEY_ID.to_owned(), access_key.to_owned());
725        }
726        if let Some(secret_key) = self.glue_secret_key() {
727            iceberg_configs.insert(AWS_SECRET_ACCESS_KEY.to_owned(), secret_key.to_owned());
728        }
729        // s3
730        if let Some(region) = &self.s3_region {
731            iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
732        }
733        if let Some(endpoint) = &self.s3_endpoint {
734            iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
735        }
736        if let Some(access_key) = &self.s3_access_key {
737            iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
738        }
739        if let Some(secret_key) = &self.s3_secret_key {
740            iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
741        }
742        if let Some(role_arn) = &self.s3_iam_role_arn {
743            iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
744        }
745        iceberg_configs.insert(
746            S3_PATH_STYLE_ACCESS.to_owned(),
747            self.effective_s3_path_style_access().to_string(),
748        );
749        iceberg_configs.insert(
750            iceberg_catalog_glue::GLUE_CATALOG_PROP_WAREHOUSE.to_owned(),
751            self.warehouse_path
752                .clone()
753                .ok_or_else(|| anyhow!("`warehouse.path` must be set in glue catalog"))?,
754        );
755        if let Some(uri) = self.catalog_uri.as_deref() {
756            iceberg_configs.insert(
757                iceberg_catalog_glue::GLUE_CATALOG_PROP_URI.to_owned(),
758                uri.to_owned(),
759            );
760        }
761
762        Ok(CatalogBuildPlan::NativeGlue(iceberg_configs))
763    }
764
765    /// For both V1 and V2.
766    fn build_jni_catalog_configs(
767        &self,
768        catalog_impl: JniCatalogImpl,
769        java_catalog_props: &HashMap<String, String>,
770    ) -> ConnectorResult<(HashMap<String, String>, HashMap<String, String>)> {
771        let mut iceberg_configs = HashMap::new();
772        let enable_config_load = self.enable_config_load();
773        let file_io_props = {
774            let catalog_type = catalog_impl.catalog_type();
775
776            // Non-S3/Glue object-store backends only work with a REST catalog. This
777            // function is only invoked for catalog_type in {hive, snowflake, jdbc, rest,
778            // glue}, so the only accepted value here is "rest".
779            let require_rest = |backend: &str| -> ConnectorResult<()> {
780                if catalog_impl != JniCatalogImpl::Rest {
781                    bail!("{} unsupported in {} catalog", backend, catalog_type);
782                }
783                Ok(())
784            };
785
786            if let Some(region) = &self.s3_region {
787                // iceberg-rust
788                iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
789            }
790
791            if let Some(endpoint) = &self.s3_endpoint {
792                // iceberg-rust
793                iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
794            }
795
796            // iceberg-rust
797            if let Some(access_key) = &self.s3_access_key {
798                iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
799            }
800            if let Some(secret_key) = &self.s3_secret_key {
801                iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
802            }
803            if let Some(role_arn) = &self.s3_iam_role_arn {
804                iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
805            }
806            if let Some(gcs_credential) = &self.gcs_credential {
807                iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
808                require_rest("gcs")?;
809            }
810
811            if let (
812                Some(azblob_account_name),
813                Some(azblob_account_key),
814                Some(azblob_endpoint_url),
815            ) = (
816                &self.azblob_account_name,
817                &self.azblob_account_key,
818                &self.azblob_endpoint_url,
819            ) {
820                iceberg_configs.insert(AZBLOB_ACCOUNT_NAME.to_owned(), azblob_account_name.clone());
821                iceberg_configs.insert(AZBLOB_ACCOUNT_KEY.to_owned(), azblob_account_key.clone());
822                iceberg_configs.insert(AZBLOB_ENDPOINT.to_owned(), azblob_endpoint_url.clone());
823
824                require_rest("azblob")?;
825            }
826
827            // Validate adlsgen2 auth configuration before populating iceberg_configs.
828            // Treat empty and whitespace-only strings as unset — serde surfaces
829            // `adlsgen2.tenant_id = ''` (or a value with trailing `\n` from a copy-paste)
830            // as `Some("...")` which would pass `is_some()` but break downstream auth.
831            fn nonempty(v: &Option<String>) -> Option<&str> {
832                v.as_deref().filter(|s| !s.trim().is_empty())
833            }
834            let sp_tenant = nonempty(&self.adlsgen2_tenant_id);
835            let sp_client = nonempty(&self.adlsgen2_client_id);
836            let sp_secret = nonempty(&self.adlsgen2_client_secret);
837            let sp_authority = nonempty(&self.adlsgen2_authority_host);
838            let sk_account_name = nonempty(&self.adlsgen2_account_name);
839            let sk_account_key = nonempty(&self.adlsgen2_account_key);
840            let any_sp_field = sp_tenant.is_some()
841                || sp_client.is_some()
842                || sp_secret.is_some()
843                || sp_authority.is_some();
844            let all_sp_required = sp_tenant.is_some() && sp_client.is_some() && sp_secret.is_some();
845
846            if sk_account_key.is_some() && any_sp_field {
847                bail!(
848                    "adlsgen2: cannot configure both shared-key auth \
849                     (adlsgen2.account_key) and service-principal auth \
850                     (adlsgen2.tenant_id / adlsgen2.client_id / adlsgen2.client_secret / \
851                     adlsgen2.authority_host) simultaneously. Specify exactly one auth mode."
852                );
853            }
854            if any_sp_field && !all_sp_required {
855                bail!(
856                    "adlsgen2: service-principal auth requires all three of \
857                     adlsgen2.tenant_id, adlsgen2.client_id, and adlsgen2.client_secret \
858                     to be set. (adlsgen2.authority_host is optional and defaults to the \
859                     public Azure AAD endpoint.)"
860                );
861            }
862            // Defense in depth: reqsign POSTs the OAuth token request — carrying the
863            // client_secret to this host. Require a bare https origin: no userinfo,
864            // no query, no fragment, and no path beyond "/". The value itself is not
865            // echoed into error messages in case a user pasted a secret by mistake.
866            if let Some(host) = sp_authority {
867                let parsed = Url::parse(host).map_err(|_| {
868                    anyhow!(
869                        "adlsgen2.authority_host does not parse as a URL ({} chars)",
870                        host.len()
871                    )
872                })?;
873                if parsed.scheme() != "https" {
874                    bail!(
875                        "adlsgen2.authority_host must use the https scheme, got {}",
876                        parsed.scheme()
877                    );
878                }
879                if !parsed.username().is_empty() || parsed.password().is_some() {
880                    bail!("adlsgen2.authority_host must not contain userinfo");
881                }
882                if parsed.query().is_some() || parsed.fragment().is_some() {
883                    bail!("adlsgen2.authority_host must not contain a query or fragment");
884                }
885                if !matches!(parsed.path(), "" | "/") {
886                    bail!("adlsgen2.authority_host must not contain a path component");
887                }
888            }
889
890            if let (Some(account_name), Some(account_key)) = (sk_account_name, sk_account_key) {
891                iceberg_configs.insert(ADLS_ACCOUNT_NAME.to_owned(), account_name.to_owned());
892                iceberg_configs.insert(ADLS_ACCOUNT_KEY.to_owned(), account_key.to_owned());
893                require_rest("adlsgen2")?;
894            }
895
896            if let (Some(tenant_id), Some(client_id), Some(client_secret)) =
897                (sp_tenant, sp_client, sp_secret)
898            {
899                iceberg_configs.insert(ADLS_TENANT_ID.to_owned(), tenant_id.to_owned());
900                iceberg_configs.insert(ADLS_CLIENT_ID.to_owned(), client_id.to_owned());
901                iceberg_configs.insert(ADLS_CLIENT_SECRET.to_owned(), client_secret.to_owned());
902                // Strip trailing slash to prevent double slash
903                let authority_host = sp_authority
904                    .unwrap_or(ADLS_DEFAULT_AUTHORITY_HOST)
905                    .trim_end_matches('/')
906                    .to_owned();
907                iceberg_configs.insert(ADLS_AUTHORITY_HOST.to_owned(), authority_host);
908                require_rest("adlsgen2")?;
909            }
910
911            match &self.warehouse_path {
912                Some(warehouse_path) => {
913                    let (bucket, _) = {
914                        let is_s3_tables = warehouse_path.starts_with("arn:aws:s3tables");
915                        // Lakehouse Iceberg REST catalog federation uses bq:// prefix for BigQuery-managed Iceberg tables.
916                        let is_bq_catalog_federation = warehouse_path.starts_with("bq://");
917                        let url = Url::parse(warehouse_path);
918                        if (url.is_err() || is_s3_tables || is_bq_catalog_federation)
919                            && catalog_impl == JniCatalogImpl::Rest
920                        {
921                            // If the warehouse path is not a valid URL, it could be:
922                            // - A warehouse name in REST catalog
923                            // - An S3 Tables path (arn:aws:s3tables:...)
924                            // - A Lakehouse path (bq://projects/...) for Google Cloud BigQuery integration
925                            // We allow these to pass through for REST catalogs.
926                            (None, None)
927                        } else {
928                            let url = url.with_context(|| {
929                                format!("Invalid warehouse path: {}", warehouse_path)
930                            })?;
931                            let bucket = url
932                                .host_str()
933                                .with_context(|| {
934                                    format!(
935                                        "Invalid s3 path: {}, bucket is missing",
936                                        warehouse_path
937                                    )
938                                })?
939                                .to_owned();
940                            let root = url.path().trim_start_matches('/').to_owned();
941                            (Some(bucket), Some(root))
942                        }
943                    };
944
945                    if let Some(bucket) = bucket {
946                        iceberg_configs.insert("iceberg.table.io.bucket".to_owned(), bucket);
947                    }
948                }
949                None => {
950                    if catalog_impl != JniCatalogImpl::Rest {
951                        bail!("`warehouse.path` must be set in {} catalog", catalog_type);
952                    }
953                }
954            }
955            iceberg_configs.insert(
956                S3_DISABLE_CONFIG_LOAD.to_owned(),
957                (!enable_config_load).to_string(),
958            );
959
960            iceberg_configs.insert(
961                GCS_DISABLE_CONFIG_LOAD.to_owned(),
962                (!enable_config_load).to_string(),
963            );
964
965            iceberg_configs.insert(
966                S3_PATH_STYLE_ACCESS.to_owned(),
967                self.effective_s3_path_style_access().to_string(),
968            );
969
970            iceberg_configs
971        };
972
973        // Prepare jni configs, for details please see https://iceberg.apache.org/docs/latest/aws/
974        let mut java_catalog_configs = HashMap::new();
975        {
976            if let Some(uri) = self.catalog_uri.as_deref() {
977                java_catalog_configs.insert("uri".to_owned(), uri.to_owned());
978            }
979
980            if let Some(warehouse_path) = &self.warehouse_path {
981                java_catalog_configs.insert("warehouse".to_owned(), warehouse_path.clone());
982            }
983            java_catalog_configs.extend(java_catalog_props.clone());
984
985            // Set io-impl: use custom io-impl if provided, otherwise default to S3FileIO
986            let io_impl = self
987                .catalog_io_impl
988                .clone()
989                .unwrap_or_else(|| "org.apache.iceberg.aws.s3.S3FileIO".to_owned());
990            java_catalog_configs.insert("io-impl".to_owned(), io_impl);
991
992            // suppress log of FileIO like: Unclosed FileIO instance created by...
993            java_catalog_configs.insert("init-creation-stacktrace".to_owned(), "false".to_owned());
994
995            if let Some(region) = &self.s3_region {
996                java_catalog_configs.insert("client.region".to_owned(), region.clone());
997            }
998            if let Some(endpoint) = &self.s3_endpoint {
999                java_catalog_configs.insert("s3.endpoint".to_owned(), endpoint.clone());
1000            }
1001
1002            if let Some(access_key) = &self.s3_access_key {
1003                java_catalog_configs.insert("s3.access-key-id".to_owned(), access_key.clone());
1004            }
1005            if let Some(secret_key) = &self.s3_secret_key {
1006                java_catalog_configs.insert("s3.secret-access-key".to_owned(), secret_key.clone());
1007            }
1008
1009            if let Some(path_style_access) = &self.s3_path_style_access {
1010                java_catalog_configs.insert(
1011                    "s3.path-style-access".to_owned(),
1012                    path_style_access.to_string(),
1013                );
1014            }
1015
1016            let headers = self.headers()?;
1017            for (header_name, header_value) in headers {
1018                java_catalog_configs.insert(format!("header.{}", header_name), header_value);
1019            }
1020
1021            match catalog_impl {
1022                JniCatalogImpl::Rest => {
1023                    // Handle security type for REST catalog (Iceberg 1.10+)
1024                    if let Some(security) = &self.catalog_security {
1025                        match security.to_lowercase().as_str() {
1026                            "google" => {
1027                                // Google AuthManager (Iceberg 1.10+) - uses Google ADC
1028                                java_catalog_configs.insert(
1029                                    "rest.auth.type".to_owned(),
1030                                    "org.apache.iceberg.gcp.auth.GoogleAuthManager".to_owned(),
1031                                );
1032                                // Set GCP auth scopes if provided
1033                                if let Some(gcp_auth_scopes) = &self.gcp_auth_scopes {
1034                                    java_catalog_configs.insert(
1035                                        "gcp.auth.scopes".to_owned(),
1036                                        gcp_auth_scopes.clone(),
1037                                    );
1038                                }
1039                            }
1040                            "oauth2" => {
1041                                // Standard OAuth2 authentication
1042                                if let Some(credential) = &self.catalog_credential {
1043                                    java_catalog_configs
1044                                        .insert("credential".to_owned(), credential.clone());
1045                                }
1046                                if let Some(token) = &self.catalog_token {
1047                                    java_catalog_configs.insert("token".to_owned(), token.clone());
1048                                }
1049                                if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1050                                    java_catalog_configs.insert(
1051                                        "oauth2-server-uri".to_owned(),
1052                                        oauth2_server_uri.clone(),
1053                                    );
1054                                }
1055                                if let Some(scope) = &self.catalog_scope {
1056                                    java_catalog_configs.insert("scope".to_owned(), scope.clone());
1057                                }
1058                            }
1059                            "none" | "" => {
1060                                // No authentication
1061                            }
1062                            _ => {
1063                                tracing::warn!(
1064                                    "Unknown catalog.security value: {}. Supported values: none, oauth2, google",
1065                                    security
1066                                );
1067                            }
1068                        }
1069                    } else {
1070                        // Legacy behavior: use individual OAuth2 properties if security type not specified
1071                        if let Some(credential) = &self.catalog_credential {
1072                            java_catalog_configs
1073                                .insert("credential".to_owned(), credential.clone());
1074                        }
1075                        if let Some(token) = &self.catalog_token {
1076                            java_catalog_configs.insert("token".to_owned(), token.clone());
1077                        }
1078                        if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1079                            java_catalog_configs
1080                                .insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
1081                        }
1082                        if let Some(scope) = &self.catalog_scope {
1083                            java_catalog_configs.insert("scope".to_owned(), scope.clone());
1084                        }
1085                    }
1086                    if let Some(rest_signing_region) = &self.rest_signing_region {
1087                        java_catalog_configs.insert(
1088                            "rest.signing-region".to_owned(),
1089                            rest_signing_region.clone(),
1090                        );
1091                    }
1092                    if let Some(rest_signing_name) = &self.rest_signing_name {
1093                        java_catalog_configs
1094                            .insert("rest.signing-name".to_owned(), rest_signing_name.clone());
1095                    }
1096                    if let Some(rest_sigv4_enabled) = self.rest_sigv4_enabled {
1097                        java_catalog_configs.insert(
1098                            "rest.sigv4-enabled".to_owned(),
1099                            rest_sigv4_enabled.to_string(),
1100                        );
1101
1102                        if let Some(access_key) = &self.s3_access_key {
1103                            java_catalog_configs
1104                                .insert("rest.access-key-id".to_owned(), access_key.clone());
1105                        }
1106
1107                        if let Some(secret_key) = &self.s3_secret_key {
1108                            java_catalog_configs
1109                                .insert("rest.secret-access-key".to_owned(), secret_key.clone());
1110                        }
1111                    }
1112                }
1113                JniCatalogImpl::Glue => {
1114                    let glue_access_key = self.glue_access_key();
1115                    let glue_secret_key = self.glue_secret_key();
1116                    let has_glue_credentials =
1117                        glue_access_key.is_some() && glue_secret_key.is_some();
1118                    let should_configure_glue_provider = !enable_config_load
1119                        || has_glue_credentials
1120                        || self.glue_iam_role_arn.is_some();
1121
1122                    if should_configure_glue_provider {
1123                        java_catalog_configs.insert(
1124                            "client.credentials-provider".to_owned(),
1125                            "com.risingwave.connector.catalog.GlueCredentialProvider".to_owned(),
1126                        );
1127                        if let Some(region) = self.glue_region() {
1128                            java_catalog_configs.insert(
1129                                "client.credentials-provider.glue.region".to_owned(),
1130                                region.to_owned(),
1131                            );
1132                        }
1133                        if let Some(access_key) = glue_access_key {
1134                            java_catalog_configs.insert(
1135                                "client.credentials-provider.glue.access-key-id".to_owned(),
1136                                access_key.to_owned(),
1137                            );
1138                        }
1139                        if let Some(secret_key) = glue_secret_key {
1140                            java_catalog_configs.insert(
1141                                "client.credentials-provider.glue.secret-access-key".to_owned(),
1142                                secret_key.to_owned(),
1143                            );
1144                        }
1145                        if let Some(role_arn) = self.glue_iam_role_arn.as_deref() {
1146                            java_catalog_configs.insert(
1147                                "client.credentials-provider.glue.iam-role-arn".to_owned(),
1148                                role_arn.to_owned(),
1149                            );
1150                        }
1151                        if enable_config_load && !has_glue_credentials {
1152                            java_catalog_configs.insert(
1153                                "client.credentials-provider.glue.use-default-credential-chain"
1154                                    .to_owned(),
1155                                "true".to_owned(),
1156                            );
1157                        }
1158                    }
1159
1160                    if let Some(region) = self.glue_region() {
1161                        java_catalog_configs.insert("client.region".to_owned(), region.to_owned());
1162                    }
1163                    let glue_endpoint = self.glue_endpoint().map(str::to_owned).or_else(|| {
1164                        self.glue_region()
1165                            .map(|region| format!("https://glue.{}.amazonaws.com", region))
1166                    });
1167                    if let Some(endpoint) = glue_endpoint {
1168                        java_catalog_configs.insert("glue.endpoint".to_owned(), endpoint);
1169                    }
1170
1171                    if let Some(glue_id) = self.glue_id.as_deref() {
1172                        java_catalog_configs.insert("glue.id".to_owned(), glue_id.to_owned());
1173                    }
1174                    self.apply_java_s3_file_io_assume_role_configs(&mut java_catalog_configs);
1175                }
1176                JniCatalogImpl::Jdbc => {
1177                    self.apply_java_aws_client_assume_role_configs(&mut java_catalog_configs);
1178                }
1179                _ => {}
1180            }
1181        }
1182
1183        Ok((file_io_props, java_catalog_configs))
1184    }
1185
1186    fn apply_java_s3_file_io_assume_role_configs(
1187        &self,
1188        java_catalog_configs: &mut HashMap<String, String>,
1189    ) {
1190        if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1191            java_catalog_configs.insert(
1192                "s3.client-factory-impl".to_owned(),
1193                "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory".to_owned(),
1194            );
1195            java_catalog_configs.insert("s3.iam-role-arn".to_owned(), iam_role_arn.clone());
1196        }
1197    }
1198
1199    fn apply_java_aws_client_assume_role_configs(
1200        &self,
1201        java_catalog_configs: &mut HashMap<String, String>,
1202    ) {
1203        if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1204            java_catalog_configs.insert("client.assume-role.arn".to_owned(), iam_role_arn.clone());
1205            java_catalog_configs.insert(
1206                "client.factory".to_owned(),
1207                "org.apache.iceberg.aws.AssumeRoleAwsClientFactory".to_owned(),
1208            );
1209            if let Some(region) = &self.s3_region {
1210                java_catalog_configs.insert("client.assume-role.region".to_owned(), region.clone());
1211            }
1212        }
1213    }
1214}
1215
1216/// Get a globally shared object cache keyed by table UUID to avoid reuse after drop & recreate.
1217pub(crate) async fn shared_object_cache(
1218    init_object_cache: Arc<ObjectCache>,
1219    table_uuid: Uuid,
1220) -> Arc<ObjectCache> {
1221    static CACHE: LazyLock<MokaCache<Uuid, Arc<ObjectCache>>> = LazyLock::new(|| {
1222        MokaCache::builder()
1223            .max_capacity(SHARED_OBJECT_CACHE_MAX_TABLES)
1224            .build()
1225    });
1226
1227    CACHE
1228        .get_with(table_uuid, async { init_object_cache })
1229        .await
1230}
1231
1232pub async fn rebuild_table_with_shared_cache(table: Table) -> Table {
1233    let table_uuid = table.metadata().uuid();
1234    let init_object_cache = table.object_cache();
1235    let object_cache = shared_object_cache(init_object_cache, table_uuid).await;
1236    table.with_object_cache(object_cache)
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use std::collections::HashMap;
1242
1243    use super::*;
1244
1245    fn test_common(catalog_type: &str) -> IcebergCommon {
1246        IcebergCommon {
1247            catalog_type: Some(catalog_type.to_owned()),
1248            s3_region: Some("ap-southeast-2".to_owned()),
1249            s3_endpoint: None,
1250            s3_access_key: None,
1251            s3_secret_key: None,
1252            s3_iam_role_arn: None,
1253            glue_access_key: None,
1254            glue_secret_key: None,
1255            glue_iam_role_arn: None,
1256            glue_region: None,
1257            glue_endpoint: None,
1258            glue_id: None,
1259            gcs_credential: None,
1260            azblob_account_name: None,
1261            azblob_account_key: None,
1262            azblob_endpoint_url: None,
1263            adlsgen2_account_name: None,
1264            adlsgen2_account_key: None,
1265            adlsgen2_endpoint: None,
1266            adlsgen2_tenant_id: None,
1267            adlsgen2_client_id: None,
1268            adlsgen2_client_secret: None,
1269            adlsgen2_authority_host: None,
1270            warehouse_path: Some("s3://bucket/warehouse".to_owned()),
1271            catalog_name: None,
1272            catalog_uri: None,
1273            catalog_credential: None,
1274            catalog_token: None,
1275            catalog_oauth2_server_uri: None,
1276            catalog_scope: None,
1277            rest_signing_region: None,
1278            rest_signing_name: None,
1279            rest_sigv4_enabled: None,
1280            s3_path_style_access: None,
1281            enable_config_load: None,
1282            hosted_catalog: None,
1283            catalog_header: None,
1284            vended_credentials: None,
1285            catalog_security: None,
1286            gcp_auth_scopes: None,
1287            catalog_io_impl: None,
1288        }
1289    }
1290
1291    #[test]
1292    fn test_vended_rest_resolves_to_native_runtime_without_rewriting_catalog_type() {
1293        let common = IcebergCommon {
1294            vended_credentials: Some(true),
1295            ..test_common("rest")
1296        };
1297
1298        assert_eq!(common.catalog_type(), "rest");
1299        assert_eq!(
1300            common.resolve_catalog_kind().unwrap(),
1301            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust)
1302        );
1303    }
1304
1305    #[test]
1306    fn test_rest_without_vended_credentials_resolves_to_jni_runtime() {
1307        let common = test_common("rest");
1308
1309        assert_eq!(
1310            common.resolve_catalog_kind().unwrap(),
1311            IcebergCatalogKind::Rest(IcebergCatalogRuntime::JavaJni)
1312        );
1313    }
1314
1315    #[test]
1316    fn test_s3_path_style_access_preserves_existing_default() {
1317        let common = test_common("storage");
1318        assert!(common.effective_s3_path_style_access());
1319
1320        let common = IcebergCommon {
1321            s3_path_style_access: Some(false),
1322            ..common
1323        };
1324        assert!(!common.effective_s3_path_style_access());
1325    }
1326
1327    #[test]
1328    fn test_mock_v3_resolves_to_mock_catalog_for_simulation_tests() {
1329        let common = test_common("mock_v3");
1330
1331        assert_eq!(
1332            common.resolve_catalog_kind().unwrap(),
1333            IcebergCatalogKind::Mock
1334        );
1335    }
1336
1337    #[test]
1338    fn test_extract_java_catalog_props_keeps_wire_options_flat() {
1339        let options = HashMap::from([
1340            ("catalog.type".to_owned(), "rest".to_owned()),
1341            ("catalog.uri".to_owned(), "http://localhost:8181".to_owned()),
1342            ("catalog.name".to_owned(), "demo".to_owned()),
1343            ("catalog.header".to_owned(), "x=y".to_owned()),
1344            (
1345                "catalog.rest.signing_region".to_owned(),
1346                "us-east-1".to_owned(),
1347            ),
1348            ("catalog.jdbc.user".to_owned(), "rw".to_owned()),
1349        ]);
1350
1351        let java_props = iceberg_java_catalog_props_from_options(
1352            options
1353                .iter()
1354                .map(|(key, value)| (key.as_str(), value.as_str())),
1355        );
1356
1357        assert_eq!(java_props.get("rest.signing_region").unwrap(), "us-east-1");
1358        assert_eq!(java_props.get("jdbc.user").unwrap(), "rw");
1359        assert!(!java_props.contains_key("type"));
1360        assert!(!java_props.contains_key("uri"));
1361        assert!(!java_props.contains_key("name"));
1362        assert!(!java_props.contains_key("header"));
1363    }
1364
1365    #[test]
1366    fn test_glue_jni_catalog_uses_s3_assume_role_for_file_io() {
1367        let common = IcebergCommon {
1368            s3_iam_role_arn: Some("arn:aws:iam::123456789012:role/risingwave-s3".to_owned()),
1369            ..test_common("glue")
1370        };
1371
1372        let (_, java_catalog_configs) = common
1373            .build_jni_catalog_configs(JniCatalogImpl::Glue, &HashMap::new())
1374            .unwrap();
1375
1376        assert_eq!(
1377            java_catalog_configs.get("s3.client-factory-impl").unwrap(),
1378            "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory"
1379        );
1380        assert_eq!(
1381            java_catalog_configs.get("s3.iam-role-arn").unwrap(),
1382            "arn:aws:iam::123456789012:role/risingwave-s3"
1383        );
1384        assert!(!java_catalog_configs.contains_key("client.factory"));
1385    }
1386
1387    #[test]
1388    fn test_adlsgen2_service_principal_populates_file_io_configs_with_default_authority_host() {
1389        let common = test_adlsgen2_service_principal_common(None);
1390
1391        let (file_io_props, _) = common
1392            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1393            .unwrap();
1394
1395        assert_eq!(file_io_props.get(ADLS_TENANT_ID).unwrap(), "tenant-uuid");
1396        assert_eq!(file_io_props.get(ADLS_CLIENT_ID).unwrap(), "client-uuid");
1397        assert_eq!(
1398            file_io_props.get(ADLS_CLIENT_SECRET).unwrap(),
1399            "secret-value"
1400        );
1401        assert_eq!(
1402            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1403            ADLS_DEFAULT_AUTHORITY_HOST
1404        );
1405    }
1406
1407    fn test_adlsgen2_service_principal_common(authority_host: Option<&str>) -> IcebergCommon {
1408        IcebergCommon {
1409            adlsgen2_account_name: Some("acct".to_owned()),
1410            adlsgen2_tenant_id: Some("tenant-uuid".to_owned()),
1411            adlsgen2_client_id: Some("client-uuid".to_owned()),
1412            adlsgen2_client_secret: Some("secret-value".to_owned()),
1413            adlsgen2_authority_host: authority_host.map(str::to_owned),
1414            warehouse_path: Some("abfss://wh@acct.dfs.core.windows.net/wh".to_owned()),
1415            ..test_common("rest")
1416        }
1417    }
1418
1419    #[test]
1420    fn test_adlsgen2_service_principal_authority_host_override_is_respected() {
1421        let common =
1422            test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us"));
1423
1424        let (file_io_props, _) = common
1425            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1426            .unwrap();
1427
1428        assert_eq!(
1429            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1430            "https://login.microsoftonline.us"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_adlsgen2_authority_host_rejects_non_bare_https_origins() {
1436        let cases = [
1437            ("not a url", "does not parse as a URL"),
1438            (
1439                "http://login.microsoftonline.com",
1440                "must use the https scheme",
1441            ),
1442            (
1443                "https://user:pass@login.microsoftonline.com",
1444                "must not contain userinfo",
1445            ),
1446            (
1447                "https://login.microsoftonline.com?bar=baz",
1448                "must not contain a query or fragment",
1449            ),
1450            (
1451                "https://login.microsoftonline.com#frag",
1452                "must not contain a query or fragment",
1453            ),
1454            (
1455                "https://login.microsoftonline.com/foo",
1456                "must not contain a path component",
1457            ),
1458        ];
1459        for (authority_host, expected_error) in cases {
1460            let common = test_adlsgen2_service_principal_common(Some(authority_host));
1461            let err = common
1462                .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1463                .unwrap_err();
1464            assert!(
1465                format!("{:#}", err).contains(expected_error),
1466                "authority_host {authority_host:?}: expected error containing {expected_error:?}, got: {err:#}"
1467            );
1468        }
1469    }
1470
1471    #[test]
1472    fn test_adlsgen2_authority_host_trailing_slash_is_normalized() {
1473        let common =
1474            test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us/"));
1475
1476        let (file_io_props, _) = common
1477            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1478            .unwrap();
1479
1480        assert_eq!(
1481            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1482            "https://login.microsoftonline.us"
1483        );
1484    }
1485
1486    #[test]
1487    fn test_adlsgen2_rejects_mixing_shared_key_and_service_principal() {
1488        let common = IcebergCommon {
1489            adlsgen2_account_key: Some("shared-key".to_owned()),
1490            ..test_adlsgen2_service_principal_common(None)
1491        };
1492
1493        let err = common
1494            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1495            .unwrap_err();
1496        assert!(
1497            format!("{:#}", err).contains("exactly one auth mode"),
1498            "expected mutual-exclusion error, got: {err:#}"
1499        );
1500    }
1501
1502    #[test]
1503    fn test_adlsgen2_rejects_partial_service_principal_config() {
1504        let common = IcebergCommon {
1505            adlsgen2_client_secret: None,
1506            ..test_adlsgen2_service_principal_common(None)
1507        };
1508
1509        let err = common
1510            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1511            .unwrap_err();
1512        assert!(
1513            format!("{:#}", err).contains("requires all three"),
1514            "expected partial-config error, got: {err:#}"
1515        );
1516    }
1517
1518    #[test]
1519    fn test_iceberg_table_identifier_validation() {
1520        let valid_identifier = IcebergTableIdentifier {
1521            database_name: Some("valid_db".to_owned()),
1522            table_name: "test_table".to_owned(),
1523        };
1524        assert!(valid_identifier.validate().is_ok());
1525
1526        let valid_underscore = IcebergTableIdentifier {
1527            database_name: Some("valid_db_name".to_owned()),
1528            table_name: "test_table".to_owned(),
1529        };
1530        assert!(valid_underscore.validate().is_ok());
1531
1532        let no_database = IcebergTableIdentifier {
1533            database_name: None,
1534            table_name: "test_table".to_owned(),
1535        };
1536        assert!(no_database.validate().is_ok());
1537
1538        let empty_part = IcebergTableIdentifier {
1539            database_name: Some("a..b".to_owned()),
1540            table_name: "test_table".to_owned(),
1541        };
1542        let result = empty_part.validate();
1543        assert!(result.is_err());
1544        assert!(
1545            result
1546                .unwrap_err()
1547                .to_string()
1548                .contains("identifier parts must not be empty")
1549        );
1550
1551        let leading_dot = IcebergTableIdentifier {
1552            database_name: None,
1553            table_name: ".test_table".to_owned(),
1554        };
1555        let result = leading_dot.validate();
1556        assert!(result.is_err());
1557        assert!(
1558            result
1559                .unwrap_err()
1560                .to_string()
1561                .contains("identifier parts must not be empty")
1562        );
1563    }
1564
1565    #[test]
1566    fn test_iceberg_table_identifier_dots_as_namespace_separators() {
1567        let table_ident = IcebergTableIdentifier {
1568            database_name: Some("general.zia.stats".to_owned()),
1569            table_name: "tagged_security_transactions".to_owned(),
1570        }
1571        .to_table_ident()
1572        .unwrap();
1573        let namespace: Vec<_> = table_ident
1574            .namespace()
1575            .as_ref()
1576            .iter()
1577            .map(String::as_str)
1578            .collect();
1579        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1580        assert_eq!(table_ident.name(), "tagged_security_transactions");
1581
1582        let table_ident = IcebergTableIdentifier {
1583            database_name: Some("general".to_owned()),
1584            table_name: "zia.stats.tagged_security_transactions".to_owned(),
1585        }
1586        .to_table_ident()
1587        .unwrap();
1588        let namespace: Vec<_> = table_ident
1589            .namespace()
1590            .as_ref()
1591            .iter()
1592            .map(String::as_str)
1593            .collect();
1594        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1595        assert_eq!(table_ident.name(), "tagged_security_transactions");
1596
1597        let table_ident = IcebergTableIdentifier {
1598            database_name: None,
1599            table_name: "general.zia.stats.tagged_security_transactions".to_owned(),
1600        }
1601        .to_table_ident()
1602        .unwrap();
1603        let namespace: Vec<_> = table_ident
1604            .namespace()
1605            .as_ref()
1606            .iter()
1607            .map(String::as_str)
1608            .collect();
1609        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1610        assert_eq!(table_ident.name(), "tagged_security_transactions");
1611    }
1612}