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