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