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