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 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 #[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 #[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 #[serde(rename = "warehouse.path")]
114 pub warehouse_path: Option<String>,
115 #[serde(rename = "catalog.name")]
117 pub catalog_name: Option<String>,
118 #[serde(rename = "catalog.uri")]
120 pub catalog_uri: Option<String>,
121 #[serde(rename = "catalog.credential")]
124 pub catalog_credential: Option<String>,
125 #[serde(rename = "catalog.token")]
128 pub catalog_token: Option<String>,
129 #[serde(rename = "catalog.oauth2_server_uri")]
132 pub catalog_oauth2_server_uri: Option<String>,
133 #[serde(rename = "catalog.scope")]
136 pub catalog_scope: Option<String>,
137
138 #[serde(rename = "catalog.rest.signing_region")]
140 pub rest_signing_region: Option<String>,
141
142 #[serde(rename = "catalog.rest.signing_name")]
144 pub rest_signing_name: Option<String>,
145
146 #[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 #[serde(default, deserialize_with = "deserialize_optional_bool_from_string")]
162 pub enable_config_load: Option<bool>,
163
164 #[serde(
166 rename = "hosted_catalog",
167 default,
168 deserialize_with = "deserialize_optional_bool_from_string"
169 )]
170 pub hosted_catalog: Option<bool>,
171
172 #[serde(rename = "catalog.header")]
181 pub catalog_header: Option<String>,
182
183 #[serde(default, deserialize_with = "deserialize_optional_bool_from_string")]
187 pub vended_credentials: Option<bool>,
188
189 #[serde(rename = "catalog.security")]
194 pub catalog_security: Option<String>,
195
196 #[serde(rename = "gcp.auth.scopes")]
201 pub gcp_auth_scopes: Option<String>,
202
203 #[serde(rename = "catalog.io_impl")]
212 pub catalog_io_impl: Option<String>,
213}
214
215const 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
222const 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 #[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 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 build_storage_catalog_config(&self) -> ConnectorResult<CatalogBuildPlan> {
607 let warehouse = self
608 .warehouse_path
609 .clone()
610 .ok_or_else(|| anyhow!("`warehouse.path` must be set in storage catalog"))?;
611 let url = Url::parse(warehouse.as_ref())
612 .map_err(|_| anyhow!("Invalid warehouse path: {}", warehouse))?;
613
614 let config = match url.scheme() {
615 "s3" | "s3a" => StorageCatalogConfig::S3(
616 storage_catalog::StorageCatalogS3Config::builder()
617 .warehouse(warehouse)
618 .access_key(self.s3_access_key.clone())
619 .secret_key(self.s3_secret_key.clone())
620 .region(self.s3_region.clone())
621 .endpoint(self.s3_endpoint.clone())
622 .path_style_access(self.s3_path_style_access)
623 .enable_config_load(Some(self.enable_config_load()))
624 .build(),
625 ),
626 "gs" | "gcs" => StorageCatalogConfig::Gcs(
627 storage_catalog::StorageCatalogGcsConfig::builder()
628 .warehouse(warehouse)
629 .credential(self.gcs_credential.clone())
630 .enable_config_load(Some(self.enable_config_load()))
631 .build(),
632 ),
633 "azblob" => StorageCatalogConfig::Azblob(
634 storage_catalog::StorageCatalogAzblobConfig::builder()
635 .warehouse(warehouse)
636 .account_name(self.azblob_account_name.clone())
637 .account_key(self.azblob_account_key.clone())
638 .endpoint(self.azblob_endpoint_url.clone())
639 .build(),
640 ),
641 scheme => bail!("Unsupported warehouse scheme: {}", scheme),
642 };
643
644 Ok(CatalogBuildPlan::Storage(config))
645 }
646
647 fn build_native_rest_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
648 let mut iceberg_configs = HashMap::new();
649
650 if let Some(gcs_credential) = &self.gcs_credential {
652 iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
653 } else {
654 if let Some(region) = &self.s3_region {
655 iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
656 }
657 if let Some(endpoint) = &self.s3_endpoint {
658 iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
659 }
660 if let Some(access_key) = &self.s3_access_key {
661 iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
662 }
663 if let Some(secret_key) = &self.s3_secret_key {
664 iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
665 }
666 if let Some(path_style_access) = &self.s3_path_style_access {
667 iceberg_configs.insert(
668 S3_PATH_STYLE_ACCESS.to_owned(),
669 path_style_access.to_string(),
670 );
671 }
672 };
673
674 if let Some(credential) = &self.catalog_credential {
675 iceberg_configs.insert("credential".to_owned(), credential.clone());
676 }
677 if let Some(token) = &self.catalog_token {
678 iceberg_configs.insert("token".to_owned(), token.clone());
679 }
680 if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
681 iceberg_configs.insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
682 }
683 if let Some(scope) = &self.catalog_scope {
684 iceberg_configs.insert("scope".to_owned(), scope.clone());
685 }
686
687 let headers = self.headers()?;
688 for (header_name, header_value) in headers {
689 iceberg_configs.insert(format!("header.{}", header_name), header_value);
690 }
691
692 iceberg_configs.insert(
693 iceberg_catalog_rest::REST_CATALOG_PROP_URI.to_owned(),
694 self.catalog_uri
695 .clone()
696 .with_context(|| "`catalog.uri` must be set in rest catalog".to_owned())?,
697 );
698 if let Some(warehouse_path) = &self.warehouse_path {
699 iceberg_configs.insert(
700 iceberg_catalog_rest::REST_CATALOG_PROP_WAREHOUSE.to_owned(),
701 warehouse_path.clone(),
702 );
703 }
704
705 Ok(CatalogBuildPlan::NativeRest(iceberg_configs))
706 }
707
708 fn build_native_glue_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
709 let mut iceberg_configs = HashMap::new();
710 if let Some(region) = self.glue_region() {
712 iceberg_configs.insert(AWS_REGION_NAME.to_owned(), region.to_owned());
713 }
714 if let Some(access_key) = self.glue_access_key() {
715 iceberg_configs.insert(AWS_ACCESS_KEY_ID.to_owned(), access_key.to_owned());
716 }
717 if let Some(secret_key) = self.glue_secret_key() {
718 iceberg_configs.insert(AWS_SECRET_ACCESS_KEY.to_owned(), secret_key.to_owned());
719 }
720 if let Some(region) = &self.s3_region {
722 iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
723 }
724 if let Some(endpoint) = &self.s3_endpoint {
725 iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
726 }
727 if let Some(access_key) = &self.s3_access_key {
728 iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
729 }
730 if let Some(secret_key) = &self.s3_secret_key {
731 iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
732 }
733 if let Some(role_arn) = &self.s3_iam_role_arn {
734 iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
735 }
736 if let Some(path_style_access) = &self.s3_path_style_access {
737 iceberg_configs.insert(
738 S3_PATH_STYLE_ACCESS.to_owned(),
739 path_style_access.to_string(),
740 );
741 }
742 iceberg_configs.insert(
743 iceberg_catalog_glue::GLUE_CATALOG_PROP_WAREHOUSE.to_owned(),
744 self.warehouse_path
745 .clone()
746 .ok_or_else(|| anyhow!("`warehouse.path` must be set in glue catalog"))?,
747 );
748 if let Some(uri) = self.catalog_uri.as_deref() {
749 iceberg_configs.insert(
750 iceberg_catalog_glue::GLUE_CATALOG_PROP_URI.to_owned(),
751 uri.to_owned(),
752 );
753 }
754
755 Ok(CatalogBuildPlan::NativeGlue(iceberg_configs))
756 }
757
758 fn build_jni_catalog_configs(
760 &self,
761 catalog_impl: JniCatalogImpl,
762 java_catalog_props: &HashMap<String, String>,
763 ) -> ConnectorResult<(HashMap<String, String>, HashMap<String, String>)> {
764 let mut iceberg_configs = HashMap::new();
765 let enable_config_load = self.enable_config_load();
766 let file_io_props = {
767 let catalog_type = catalog_impl.catalog_type();
768
769 let require_rest = |backend: &str| -> ConnectorResult<()> {
773 if catalog_impl != JniCatalogImpl::Rest {
774 bail!("{} unsupported in {} catalog", backend, catalog_type);
775 }
776 Ok(())
777 };
778
779 if let Some(region) = &self.s3_region {
780 iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
782 }
783
784 if let Some(endpoint) = &self.s3_endpoint {
785 iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
787 }
788
789 if let Some(access_key) = &self.s3_access_key {
791 iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
792 }
793 if let Some(secret_key) = &self.s3_secret_key {
794 iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
795 }
796 if let Some(role_arn) = &self.s3_iam_role_arn {
797 iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
798 }
799 if let Some(gcs_credential) = &self.gcs_credential {
800 iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
801 require_rest("gcs")?;
802 }
803
804 if let (
805 Some(azblob_account_name),
806 Some(azblob_account_key),
807 Some(azblob_endpoint_url),
808 ) = (
809 &self.azblob_account_name,
810 &self.azblob_account_key,
811 &self.azblob_endpoint_url,
812 ) {
813 iceberg_configs.insert(AZBLOB_ACCOUNT_NAME.to_owned(), azblob_account_name.clone());
814 iceberg_configs.insert(AZBLOB_ACCOUNT_KEY.to_owned(), azblob_account_key.clone());
815 iceberg_configs.insert(AZBLOB_ENDPOINT.to_owned(), azblob_endpoint_url.clone());
816
817 require_rest("azblob")?;
818 }
819
820 fn nonempty(v: &Option<String>) -> Option<&str> {
825 v.as_deref().filter(|s| !s.trim().is_empty())
826 }
827 let sp_tenant = nonempty(&self.adlsgen2_tenant_id);
828 let sp_client = nonempty(&self.adlsgen2_client_id);
829 let sp_secret = nonempty(&self.adlsgen2_client_secret);
830 let sp_authority = nonempty(&self.adlsgen2_authority_host);
831 let sk_account_name = nonempty(&self.adlsgen2_account_name);
832 let sk_account_key = nonempty(&self.adlsgen2_account_key);
833 let any_sp_field = sp_tenant.is_some()
834 || sp_client.is_some()
835 || sp_secret.is_some()
836 || sp_authority.is_some();
837 let all_sp_required = sp_tenant.is_some() && sp_client.is_some() && sp_secret.is_some();
838
839 if sk_account_key.is_some() && any_sp_field {
840 bail!(
841 "adlsgen2: cannot configure both shared-key auth \
842 (adlsgen2.account_key) and service-principal auth \
843 (adlsgen2.tenant_id / adlsgen2.client_id / adlsgen2.client_secret / \
844 adlsgen2.authority_host) simultaneously. Specify exactly one auth mode."
845 );
846 }
847 if any_sp_field && !all_sp_required {
848 bail!(
849 "adlsgen2: service-principal auth requires all three of \
850 adlsgen2.tenant_id, adlsgen2.client_id, and adlsgen2.client_secret \
851 to be set. (adlsgen2.authority_host is optional and defaults to the \
852 public Azure AAD endpoint.)"
853 );
854 }
855 if let Some(host) = sp_authority {
860 let parsed = Url::parse(host).map_err(|_| {
861 anyhow!(
862 "adlsgen2.authority_host does not parse as a URL ({} chars)",
863 host.len()
864 )
865 })?;
866 if parsed.scheme() != "https" {
867 bail!(
868 "adlsgen2.authority_host must use the https scheme, got {}",
869 parsed.scheme()
870 );
871 }
872 if !parsed.username().is_empty() || parsed.password().is_some() {
873 bail!("adlsgen2.authority_host must not contain userinfo");
874 }
875 if parsed.query().is_some() || parsed.fragment().is_some() {
876 bail!("adlsgen2.authority_host must not contain a query or fragment");
877 }
878 if !matches!(parsed.path(), "" | "/") {
879 bail!("adlsgen2.authority_host must not contain a path component");
880 }
881 }
882
883 if let (Some(account_name), Some(account_key)) = (sk_account_name, sk_account_key) {
884 iceberg_configs.insert(ADLS_ACCOUNT_NAME.to_owned(), account_name.to_owned());
885 iceberg_configs.insert(ADLS_ACCOUNT_KEY.to_owned(), account_key.to_owned());
886 require_rest("adlsgen2")?;
887 }
888
889 if let (Some(tenant_id), Some(client_id), Some(client_secret)) =
890 (sp_tenant, sp_client, sp_secret)
891 {
892 iceberg_configs.insert(ADLS_TENANT_ID.to_owned(), tenant_id.to_owned());
893 iceberg_configs.insert(ADLS_CLIENT_ID.to_owned(), client_id.to_owned());
894 iceberg_configs.insert(ADLS_CLIENT_SECRET.to_owned(), client_secret.to_owned());
895 let authority_host = sp_authority
897 .unwrap_or(ADLS_DEFAULT_AUTHORITY_HOST)
898 .trim_end_matches('/')
899 .to_owned();
900 iceberg_configs.insert(ADLS_AUTHORITY_HOST.to_owned(), authority_host);
901 require_rest("adlsgen2")?;
902 }
903
904 match &self.warehouse_path {
905 Some(warehouse_path) => {
906 let (bucket, _) = {
907 let is_s3_tables = warehouse_path.starts_with("arn:aws:s3tables");
908 let is_bq_catalog_federation = warehouse_path.starts_with("bq://");
910 let url = Url::parse(warehouse_path);
911 if (url.is_err() || is_s3_tables || is_bq_catalog_federation)
912 && catalog_impl == JniCatalogImpl::Rest
913 {
914 (None, None)
920 } else {
921 let url = url.with_context(|| {
922 format!("Invalid warehouse path: {}", warehouse_path)
923 })?;
924 let bucket = url
925 .host_str()
926 .with_context(|| {
927 format!(
928 "Invalid s3 path: {}, bucket is missing",
929 warehouse_path
930 )
931 })?
932 .to_owned();
933 let root = url.path().trim_start_matches('/').to_owned();
934 (Some(bucket), Some(root))
935 }
936 };
937
938 if let Some(bucket) = bucket {
939 iceberg_configs.insert("iceberg.table.io.bucket".to_owned(), bucket);
940 }
941 }
942 None => {
943 if catalog_impl != JniCatalogImpl::Rest {
944 bail!("`warehouse.path` must be set in {} catalog", catalog_type);
945 }
946 }
947 }
948 iceberg_configs.insert(
949 S3_DISABLE_CONFIG_LOAD.to_owned(),
950 (!enable_config_load).to_string(),
951 );
952
953 iceberg_configs.insert(
954 GCS_DISABLE_CONFIG_LOAD.to_owned(),
955 (!enable_config_load).to_string(),
956 );
957
958 if let Some(path_style_access) = self.s3_path_style_access {
959 iceberg_configs.insert(
960 S3_PATH_STYLE_ACCESS.to_owned(),
961 path_style_access.to_string(),
962 );
963 }
964
965 iceberg_configs
966 };
967
968 let mut java_catalog_configs = HashMap::new();
970 {
971 if let Some(uri) = self.catalog_uri.as_deref() {
972 java_catalog_configs.insert("uri".to_owned(), uri.to_owned());
973 }
974
975 if let Some(warehouse_path) = &self.warehouse_path {
976 java_catalog_configs.insert("warehouse".to_owned(), warehouse_path.clone());
977 }
978 java_catalog_configs.extend(java_catalog_props.clone());
979
980 let io_impl = self
982 .catalog_io_impl
983 .clone()
984 .unwrap_or_else(|| "org.apache.iceberg.aws.s3.S3FileIO".to_owned());
985 java_catalog_configs.insert("io-impl".to_owned(), io_impl);
986
987 java_catalog_configs.insert("init-creation-stacktrace".to_owned(), "false".to_owned());
989
990 if let Some(region) = &self.s3_region {
991 java_catalog_configs.insert("client.region".to_owned(), region.clone());
992 }
993 if let Some(endpoint) = &self.s3_endpoint {
994 java_catalog_configs.insert("s3.endpoint".to_owned(), endpoint.clone());
995 }
996
997 if let Some(access_key) = &self.s3_access_key {
998 java_catalog_configs.insert("s3.access-key-id".to_owned(), access_key.clone());
999 }
1000 if let Some(secret_key) = &self.s3_secret_key {
1001 java_catalog_configs.insert("s3.secret-access-key".to_owned(), secret_key.clone());
1002 }
1003
1004 if let Some(path_style_access) = &self.s3_path_style_access {
1005 java_catalog_configs.insert(
1006 "s3.path-style-access".to_owned(),
1007 path_style_access.to_string(),
1008 );
1009 }
1010
1011 let headers = self.headers()?;
1012 for (header_name, header_value) in headers {
1013 java_catalog_configs.insert(format!("header.{}", header_name), header_value);
1014 }
1015
1016 match catalog_impl {
1017 JniCatalogImpl::Rest => {
1018 if let Some(security) = &self.catalog_security {
1020 match security.to_lowercase().as_str() {
1021 "google" => {
1022 java_catalog_configs.insert(
1024 "rest.auth.type".to_owned(),
1025 "org.apache.iceberg.gcp.auth.GoogleAuthManager".to_owned(),
1026 );
1027 if let Some(gcp_auth_scopes) = &self.gcp_auth_scopes {
1029 java_catalog_configs.insert(
1030 "gcp.auth.scopes".to_owned(),
1031 gcp_auth_scopes.clone(),
1032 );
1033 }
1034 }
1035 "oauth2" => {
1036 if let Some(credential) = &self.catalog_credential {
1038 java_catalog_configs
1039 .insert("credential".to_owned(), credential.clone());
1040 }
1041 if let Some(token) = &self.catalog_token {
1042 java_catalog_configs.insert("token".to_owned(), token.clone());
1043 }
1044 if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1045 java_catalog_configs.insert(
1046 "oauth2-server-uri".to_owned(),
1047 oauth2_server_uri.clone(),
1048 );
1049 }
1050 if let Some(scope) = &self.catalog_scope {
1051 java_catalog_configs.insert("scope".to_owned(), scope.clone());
1052 }
1053 }
1054 "none" | "" => {
1055 }
1057 _ => {
1058 tracing::warn!(
1059 "Unknown catalog.security value: {}. Supported values: none, oauth2, google",
1060 security
1061 );
1062 }
1063 }
1064 } else {
1065 if let Some(credential) = &self.catalog_credential {
1067 java_catalog_configs
1068 .insert("credential".to_owned(), credential.clone());
1069 }
1070 if let Some(token) = &self.catalog_token {
1071 java_catalog_configs.insert("token".to_owned(), token.clone());
1072 }
1073 if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1074 java_catalog_configs
1075 .insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
1076 }
1077 if let Some(scope) = &self.catalog_scope {
1078 java_catalog_configs.insert("scope".to_owned(), scope.clone());
1079 }
1080 }
1081 if let Some(rest_signing_region) = &self.rest_signing_region {
1082 java_catalog_configs.insert(
1083 "rest.signing-region".to_owned(),
1084 rest_signing_region.clone(),
1085 );
1086 }
1087 if let Some(rest_signing_name) = &self.rest_signing_name {
1088 java_catalog_configs
1089 .insert("rest.signing-name".to_owned(), rest_signing_name.clone());
1090 }
1091 if let Some(rest_sigv4_enabled) = self.rest_sigv4_enabled {
1092 java_catalog_configs.insert(
1093 "rest.sigv4-enabled".to_owned(),
1094 rest_sigv4_enabled.to_string(),
1095 );
1096
1097 if let Some(access_key) = &self.s3_access_key {
1098 java_catalog_configs
1099 .insert("rest.access-key-id".to_owned(), access_key.clone());
1100 }
1101
1102 if let Some(secret_key) = &self.s3_secret_key {
1103 java_catalog_configs
1104 .insert("rest.secret-access-key".to_owned(), secret_key.clone());
1105 }
1106 }
1107 }
1108 JniCatalogImpl::Glue => {
1109 let glue_access_key = self.glue_access_key();
1110 let glue_secret_key = self.glue_secret_key();
1111 let has_glue_credentials =
1112 glue_access_key.is_some() && glue_secret_key.is_some();
1113 let should_configure_glue_provider = !enable_config_load
1114 || has_glue_credentials
1115 || self.glue_iam_role_arn.is_some();
1116
1117 if should_configure_glue_provider {
1118 java_catalog_configs.insert(
1119 "client.credentials-provider".to_owned(),
1120 "com.risingwave.connector.catalog.GlueCredentialProvider".to_owned(),
1121 );
1122 if let Some(region) = self.glue_region() {
1123 java_catalog_configs.insert(
1124 "client.credentials-provider.glue.region".to_owned(),
1125 region.to_owned(),
1126 );
1127 }
1128 if let Some(access_key) = glue_access_key {
1129 java_catalog_configs.insert(
1130 "client.credentials-provider.glue.access-key-id".to_owned(),
1131 access_key.to_owned(),
1132 );
1133 }
1134 if let Some(secret_key) = glue_secret_key {
1135 java_catalog_configs.insert(
1136 "client.credentials-provider.glue.secret-access-key".to_owned(),
1137 secret_key.to_owned(),
1138 );
1139 }
1140 if let Some(role_arn) = self.glue_iam_role_arn.as_deref() {
1141 java_catalog_configs.insert(
1142 "client.credentials-provider.glue.iam-role-arn".to_owned(),
1143 role_arn.to_owned(),
1144 );
1145 }
1146 if enable_config_load && !has_glue_credentials {
1147 java_catalog_configs.insert(
1148 "client.credentials-provider.glue.use-default-credential-chain"
1149 .to_owned(),
1150 "true".to_owned(),
1151 );
1152 }
1153 }
1154
1155 if let Some(region) = self.glue_region() {
1156 java_catalog_configs.insert("client.region".to_owned(), region.to_owned());
1157 }
1158 let glue_endpoint = self.glue_endpoint().map(str::to_owned).or_else(|| {
1159 self.glue_region()
1160 .map(|region| format!("https://glue.{}.amazonaws.com", region))
1161 });
1162 if let Some(endpoint) = glue_endpoint {
1163 java_catalog_configs.insert("glue.endpoint".to_owned(), endpoint);
1164 }
1165
1166 if let Some(glue_id) = self.glue_id.as_deref() {
1167 java_catalog_configs.insert("glue.id".to_owned(), glue_id.to_owned());
1168 }
1169 self.apply_java_s3_file_io_assume_role_configs(&mut java_catalog_configs);
1170 }
1171 JniCatalogImpl::Jdbc => {
1172 self.apply_java_aws_client_assume_role_configs(&mut java_catalog_configs);
1173 }
1174 _ => {}
1175 }
1176 }
1177
1178 Ok((file_io_props, java_catalog_configs))
1179 }
1180
1181 fn apply_java_s3_file_io_assume_role_configs(
1182 &self,
1183 java_catalog_configs: &mut HashMap<String, String>,
1184 ) {
1185 if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1186 java_catalog_configs.insert(
1187 "s3.client-factory-impl".to_owned(),
1188 "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory".to_owned(),
1189 );
1190 java_catalog_configs.insert("s3.iam-role-arn".to_owned(), iam_role_arn.clone());
1191 }
1192 }
1193
1194 fn apply_java_aws_client_assume_role_configs(
1195 &self,
1196 java_catalog_configs: &mut HashMap<String, String>,
1197 ) {
1198 if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1199 java_catalog_configs.insert("client.assume-role.arn".to_owned(), iam_role_arn.clone());
1200 java_catalog_configs.insert(
1201 "client.factory".to_owned(),
1202 "org.apache.iceberg.aws.AssumeRoleAwsClientFactory".to_owned(),
1203 );
1204 if let Some(region) = &self.s3_region {
1205 java_catalog_configs.insert("client.assume-role.region".to_owned(), region.clone());
1206 }
1207 }
1208 }
1209}
1210
1211pub(crate) async fn shared_object_cache(
1213 init_object_cache: Arc<ObjectCache>,
1214 table_uuid: Uuid,
1215) -> Arc<ObjectCache> {
1216 static CACHE: LazyLock<MokaCache<Uuid, Arc<ObjectCache>>> = LazyLock::new(|| {
1217 MokaCache::builder()
1218 .max_capacity(SHARED_OBJECT_CACHE_MAX_TABLES)
1219 .build()
1220 });
1221
1222 CACHE
1223 .get_with(table_uuid, async { init_object_cache })
1224 .await
1225}
1226
1227pub async fn rebuild_table_with_shared_cache(table: Table) -> Table {
1228 let table_uuid = table.metadata().uuid();
1229 let init_object_cache = table.object_cache();
1230 let object_cache = shared_object_cache(init_object_cache, table_uuid).await;
1231 table.with_object_cache(object_cache)
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use std::collections::HashMap;
1237
1238 use super::*;
1239
1240 fn test_common(catalog_type: &str) -> IcebergCommon {
1241 IcebergCommon {
1242 catalog_type: Some(catalog_type.to_owned()),
1243 s3_region: Some("ap-southeast-2".to_owned()),
1244 s3_endpoint: None,
1245 s3_access_key: None,
1246 s3_secret_key: None,
1247 s3_iam_role_arn: None,
1248 glue_access_key: None,
1249 glue_secret_key: None,
1250 glue_iam_role_arn: None,
1251 glue_region: None,
1252 glue_endpoint: None,
1253 glue_id: None,
1254 gcs_credential: None,
1255 azblob_account_name: None,
1256 azblob_account_key: None,
1257 azblob_endpoint_url: None,
1258 adlsgen2_account_name: None,
1259 adlsgen2_account_key: None,
1260 adlsgen2_endpoint: None,
1261 adlsgen2_tenant_id: None,
1262 adlsgen2_client_id: None,
1263 adlsgen2_client_secret: None,
1264 adlsgen2_authority_host: None,
1265 warehouse_path: Some("s3://bucket/warehouse".to_owned()),
1266 catalog_name: None,
1267 catalog_uri: None,
1268 catalog_credential: None,
1269 catalog_token: None,
1270 catalog_oauth2_server_uri: None,
1271 catalog_scope: None,
1272 rest_signing_region: None,
1273 rest_signing_name: None,
1274 rest_sigv4_enabled: None,
1275 s3_path_style_access: None,
1276 enable_config_load: None,
1277 hosted_catalog: None,
1278 catalog_header: None,
1279 vended_credentials: None,
1280 catalog_security: None,
1281 gcp_auth_scopes: None,
1282 catalog_io_impl: None,
1283 }
1284 }
1285
1286 #[test]
1287 fn test_vended_rest_resolves_to_native_runtime_without_rewriting_catalog_type() {
1288 let common = IcebergCommon {
1289 vended_credentials: Some(true),
1290 ..test_common("rest")
1291 };
1292
1293 assert_eq!(common.catalog_type(), "rest");
1294 assert_eq!(
1295 common.resolve_catalog_kind().unwrap(),
1296 IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust)
1297 );
1298 }
1299
1300 #[test]
1301 fn test_rest_without_vended_credentials_resolves_to_jni_runtime() {
1302 let common = test_common("rest");
1303
1304 assert_eq!(
1305 common.resolve_catalog_kind().unwrap(),
1306 IcebergCatalogKind::Rest(IcebergCatalogRuntime::JavaJni)
1307 );
1308 }
1309
1310 #[test]
1311 fn test_mock_v3_resolves_to_mock_catalog_for_simulation_tests() {
1312 let common = test_common("mock_v3");
1313
1314 assert_eq!(
1315 common.resolve_catalog_kind().unwrap(),
1316 IcebergCatalogKind::Mock
1317 );
1318 }
1319
1320 #[test]
1321 fn test_extract_java_catalog_props_keeps_wire_options_flat() {
1322 let options = HashMap::from([
1323 ("catalog.type".to_owned(), "rest".to_owned()),
1324 ("catalog.uri".to_owned(), "http://localhost:8181".to_owned()),
1325 ("catalog.name".to_owned(), "demo".to_owned()),
1326 ("catalog.header".to_owned(), "x=y".to_owned()),
1327 (
1328 "catalog.rest.signing_region".to_owned(),
1329 "us-east-1".to_owned(),
1330 ),
1331 ("catalog.jdbc.user".to_owned(), "rw".to_owned()),
1332 ]);
1333
1334 let java_props = iceberg_java_catalog_props_from_options(
1335 options
1336 .iter()
1337 .map(|(key, value)| (key.as_str(), value.as_str())),
1338 );
1339
1340 assert_eq!(java_props.get("rest.signing_region").unwrap(), "us-east-1");
1341 assert_eq!(java_props.get("jdbc.user").unwrap(), "rw");
1342 assert!(!java_props.contains_key("type"));
1343 assert!(!java_props.contains_key("uri"));
1344 assert!(!java_props.contains_key("name"));
1345 assert!(!java_props.contains_key("header"));
1346 }
1347
1348 #[test]
1349 fn test_glue_jni_catalog_uses_s3_assume_role_for_file_io() {
1350 let common = IcebergCommon {
1351 s3_iam_role_arn: Some("arn:aws:iam::123456789012:role/risingwave-s3".to_owned()),
1352 ..test_common("glue")
1353 };
1354
1355 let (_, java_catalog_configs) = common
1356 .build_jni_catalog_configs(JniCatalogImpl::Glue, &HashMap::new())
1357 .unwrap();
1358
1359 assert_eq!(
1360 java_catalog_configs.get("s3.client-factory-impl").unwrap(),
1361 "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory"
1362 );
1363 assert_eq!(
1364 java_catalog_configs.get("s3.iam-role-arn").unwrap(),
1365 "arn:aws:iam::123456789012:role/risingwave-s3"
1366 );
1367 assert!(!java_catalog_configs.contains_key("client.factory"));
1368 }
1369
1370 #[test]
1371 fn test_adlsgen2_service_principal_populates_file_io_configs_with_default_authority_host() {
1372 let common = test_adlsgen2_service_principal_common(None);
1373
1374 let (file_io_props, _) = common
1375 .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1376 .unwrap();
1377
1378 assert_eq!(file_io_props.get(ADLS_TENANT_ID).unwrap(), "tenant-uuid");
1379 assert_eq!(file_io_props.get(ADLS_CLIENT_ID).unwrap(), "client-uuid");
1380 assert_eq!(
1381 file_io_props.get(ADLS_CLIENT_SECRET).unwrap(),
1382 "secret-value"
1383 );
1384 assert_eq!(
1385 file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1386 ADLS_DEFAULT_AUTHORITY_HOST
1387 );
1388 }
1389
1390 fn test_adlsgen2_service_principal_common(authority_host: Option<&str>) -> IcebergCommon {
1391 IcebergCommon {
1392 adlsgen2_account_name: Some("acct".to_owned()),
1393 adlsgen2_tenant_id: Some("tenant-uuid".to_owned()),
1394 adlsgen2_client_id: Some("client-uuid".to_owned()),
1395 adlsgen2_client_secret: Some("secret-value".to_owned()),
1396 adlsgen2_authority_host: authority_host.map(str::to_owned),
1397 warehouse_path: Some("abfss://wh@acct.dfs.core.windows.net/wh".to_owned()),
1398 ..test_common("rest")
1399 }
1400 }
1401
1402 #[test]
1403 fn test_adlsgen2_service_principal_authority_host_override_is_respected() {
1404 let common =
1405 test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us"));
1406
1407 let (file_io_props, _) = common
1408 .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1409 .unwrap();
1410
1411 assert_eq!(
1412 file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1413 "https://login.microsoftonline.us"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_adlsgen2_authority_host_rejects_non_bare_https_origins() {
1419 let cases = [
1420 ("not a url", "does not parse as a URL"),
1421 (
1422 "http://login.microsoftonline.com",
1423 "must use the https scheme",
1424 ),
1425 (
1426 "https://user:pass@login.microsoftonline.com",
1427 "must not contain userinfo",
1428 ),
1429 (
1430 "https://login.microsoftonline.com?bar=baz",
1431 "must not contain a query or fragment",
1432 ),
1433 (
1434 "https://login.microsoftonline.com#frag",
1435 "must not contain a query or fragment",
1436 ),
1437 (
1438 "https://login.microsoftonline.com/foo",
1439 "must not contain a path component",
1440 ),
1441 ];
1442 for (authority_host, expected_error) in cases {
1443 let common = test_adlsgen2_service_principal_common(Some(authority_host));
1444 let err = common
1445 .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1446 .unwrap_err();
1447 assert!(
1448 format!("{:#}", err).contains(expected_error),
1449 "authority_host {authority_host:?}: expected error containing {expected_error:?}, got: {err:#}"
1450 );
1451 }
1452 }
1453
1454 #[test]
1455 fn test_adlsgen2_authority_host_trailing_slash_is_normalized() {
1456 let common =
1457 test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us/"));
1458
1459 let (file_io_props, _) = common
1460 .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1461 .unwrap();
1462
1463 assert_eq!(
1464 file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1465 "https://login.microsoftonline.us"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_adlsgen2_rejects_mixing_shared_key_and_service_principal() {
1471 let common = IcebergCommon {
1472 adlsgen2_account_key: Some("shared-key".to_owned()),
1473 ..test_adlsgen2_service_principal_common(None)
1474 };
1475
1476 let err = common
1477 .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1478 .unwrap_err();
1479 assert!(
1480 format!("{:#}", err).contains("exactly one auth mode"),
1481 "expected mutual-exclusion error, got: {err:#}"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_adlsgen2_rejects_partial_service_principal_config() {
1487 let common = IcebergCommon {
1488 adlsgen2_client_secret: None,
1489 ..test_adlsgen2_service_principal_common(None)
1490 };
1491
1492 let err = common
1493 .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1494 .unwrap_err();
1495 assert!(
1496 format!("{:#}", err).contains("requires all three"),
1497 "expected partial-config error, got: {err:#}"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_iceberg_table_identifier_validation() {
1503 let valid_identifier = IcebergTableIdentifier {
1504 database_name: Some("valid_db".to_owned()),
1505 table_name: "test_table".to_owned(),
1506 };
1507 assert!(valid_identifier.validate().is_ok());
1508
1509 let valid_underscore = IcebergTableIdentifier {
1510 database_name: Some("valid_db_name".to_owned()),
1511 table_name: "test_table".to_owned(),
1512 };
1513 assert!(valid_underscore.validate().is_ok());
1514
1515 let no_database = IcebergTableIdentifier {
1516 database_name: None,
1517 table_name: "test_table".to_owned(),
1518 };
1519 assert!(no_database.validate().is_ok());
1520
1521 let empty_part = IcebergTableIdentifier {
1522 database_name: Some("a..b".to_owned()),
1523 table_name: "test_table".to_owned(),
1524 };
1525 let result = empty_part.validate();
1526 assert!(result.is_err());
1527 assert!(
1528 result
1529 .unwrap_err()
1530 .to_string()
1531 .contains("identifier parts must not be empty")
1532 );
1533
1534 let leading_dot = IcebergTableIdentifier {
1535 database_name: None,
1536 table_name: ".test_table".to_owned(),
1537 };
1538 let result = leading_dot.validate();
1539 assert!(result.is_err());
1540 assert!(
1541 result
1542 .unwrap_err()
1543 .to_string()
1544 .contains("identifier parts must not be empty")
1545 );
1546 }
1547
1548 #[test]
1549 fn test_iceberg_table_identifier_dots_as_namespace_separators() {
1550 let table_ident = IcebergTableIdentifier {
1551 database_name: Some("general.zia.stats".to_owned()),
1552 table_name: "tagged_security_transactions".to_owned(),
1553 }
1554 .to_table_ident()
1555 .unwrap();
1556 let namespace: Vec<_> = table_ident
1557 .namespace()
1558 .as_ref()
1559 .iter()
1560 .map(String::as_str)
1561 .collect();
1562 assert_eq!(namespace, vec!["general", "zia", "stats"]);
1563 assert_eq!(table_ident.name(), "tagged_security_transactions");
1564
1565 let table_ident = IcebergTableIdentifier {
1566 database_name: Some("general".to_owned()),
1567 table_name: "zia.stats.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: None,
1582 table_name: "general.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}