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