use std::collections::HashMap;
use std::sync::Arc;
use iceberg::{Catalog, CatalogBuilder, NamespaceIdent};
use iceberg_catalog_sql::{
SQL_CATALOG_PROP_BIND_STYLE, SQL_CATALOG_PROP_URI, SQL_CATALOG_PROP_WAREHOUSE, SqlBindStyle,
SqlCatalogBuilder,
};
use crate::error::{Error, Result};
use super::IcebergCold;
#[derive(Debug, Clone, Default)]
pub struct WarehouseAuth {
pub region: Option<String>,
pub endpoint: Option<String>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
}
fn warehouse_scheme(warehouse_uri: &str) -> &str {
warehouse_uri
.split_once("://")
.map_or("file", |(scheme, _)| scheme)
}
fn is_s3_scheme(scheme: &str) -> bool {
matches!(scheme, "s3" | "s3a" | "s3n" | "minio" | "r2")
}
fn warehouse_factory(warehouse_uri: &str) -> Result<Arc<dyn iceberg::io::StorageFactory>> {
use iceberg_storage_opendal::OpenDalStorageFactory;
let scheme = warehouse_scheme(warehouse_uri);
Ok(match scheme {
"file" => Arc::new(OpenDalStorageFactory::Fs),
"memory" => Arc::new(OpenDalStorageFactory::Memory),
"s3" | "s3a" | "s3n" | "minio" | "r2" => {
#[cfg(feature = "object-store-s3")]
{
Arc::new(OpenDalStorageFactory::S3 {
customized_credential_load: None,
})
}
#[cfg(not(feature = "object-store-s3"))]
return Err(Error::Storage(format!(
"warehouse scheme {scheme:?} needs the meterstore `object-store-s3` feature, which was not compiled in"
)));
}
"gs" | "gcs" => {
#[cfg(feature = "object-store-gcs")]
{
Arc::new(OpenDalStorageFactory::Gcs)
}
#[cfg(not(feature = "object-store-gcs"))]
return Err(Error::Storage(format!(
"warehouse scheme {scheme:?} needs the meterstore `object-store-gcs` feature, which was not compiled in"
)));
}
"abfss" | "abfs" | "azdls" => {
#[cfg(feature = "object-store-azure")]
{
Arc::new(OpenDalStorageFactory::Azdls)
}
#[cfg(not(feature = "object-store-azure"))]
return Err(Error::Storage(format!(
"warehouse scheme {scheme:?} needs the meterstore `object-store-azure` feature, which was not compiled in"
)));
}
other => {
return Err(Error::Storage(format!(
"unsupported warehouse scheme {other:?}"
)));
}
})
}
fn apply_warehouse_auth(
props: &mut HashMap<String, String>,
warehouse_uri: &str,
auth: &WarehouseAuth,
) {
use iceberg::io::{
CLIENT_REGION, S3_ACCESS_KEY_ID, S3_ENDPOINT, S3_PATH_STYLE_ACCESS, S3_REGION,
S3_SECRET_ACCESS_KEY,
};
if !is_s3_scheme(warehouse_scheme(warehouse_uri)) {
return;
}
if let Some(region) = &auth.region {
props.insert(S3_REGION.into(), region.clone());
props.insert(CLIENT_REGION.into(), region.clone());
}
if let Some(endpoint) = &auth.endpoint {
props.insert(S3_ENDPOINT.into(), endpoint.clone());
props.insert(S3_PATH_STYLE_ACCESS.into(), "true".into());
}
if let Some(key) = &auth.access_key_id {
props.insert(S3_ACCESS_KEY_ID.into(), key.clone());
}
if let Some(secret) = &auth.secret_access_key {
props.insert(S3_SECRET_ACCESS_KEY.into(), secret.clone());
}
}
#[derive(Debug, Clone)]
pub struct IcebergSqlCatalog<'a> {
pub database_url: &'a str,
pub warehouse_uri: &'a str,
pub catalog_name: &'a str,
pub namespace: &'a str,
pub file_target_bytes: usize,
pub metadata_pool_max_connections: u32,
pub auth: &'a WarehouseAuth,
}
impl IcebergSqlCatalog<'_> {
pub async fn build(&self) -> Result<ColdTier> {
let storage = |e: iceberg::Error| Error::Storage(e.to_string());
let mut props = HashMap::from([
(
SQL_CATALOG_PROP_URI.to_string(),
self.database_url.to_string(),
),
(
SQL_CATALOG_PROP_WAREHOUSE.to_string(),
self.warehouse_uri.to_string(),
),
(
SQL_CATALOG_PROP_BIND_STYLE.to_string(),
SqlBindStyle::DollarNumeric.to_string(),
),
(
"pool.max-connections".to_string(),
self.metadata_pool_max_connections.to_string(),
),
]);
apply_warehouse_auth(&mut props, self.warehouse_uri, self.auth);
let catalog: Arc<dyn Catalog> = Arc::new(
SqlCatalogBuilder::default()
.with_storage_factory(warehouse_factory(self.warehouse_uri)?)
.load(self.catalog_name, props)
.await
.map_err(storage)?,
);
let cold = Arc::new(IcebergCold::new(
Arc::clone(&catalog),
NamespaceIdent::new(self.namespace.to_string()),
self.file_target_bytes,
));
Ok(ColdTier { cold, catalog })
}
}
#[cfg(feature = "s3tables")]
#[derive(Debug)]
pub struct S3TablesCatalog<'a> {
pub table_bucket_arn: &'a str,
pub namespace: &'a str,
pub file_target_bytes: usize,
pub endpoint_url: Option<&'a str>,
pub region: Option<&'a str>,
}
#[cfg(feature = "s3tables")]
impl S3TablesCatalog<'_> {
pub async fn build(&self) -> Result<ColdTier> {
use iceberg_catalog_s3tables::{
S3TABLES_CATALOG_PROP_ENDPOINT_URL, S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN,
S3TablesCatalogBuilder,
};
if !self.table_bucket_arn.starts_with("arn:") {
return Err(Error::config(format!(
"table_bucket_arn {:?} is not an ARN. S3 Tables names a warehouse by \
bucket ARN rather than by URI: \
arn:aws:s3tables:<region>:<account>:bucket/<name>",
self.table_bucket_arn
)));
}
let mut props = HashMap::from([(
S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
self.table_bucket_arn.to_string(),
)]);
if let Some(endpoint) = self.endpoint_url {
props.insert(
S3TABLES_CATALOG_PROP_ENDPOINT_URL.to_string(),
endpoint.to_string(),
);
}
if let Some(region) = self.region {
props.insert("region_name".to_string(), region.to_string());
}
let catalog: Arc<dyn Catalog> = Arc::new(
S3TablesCatalogBuilder::default()
.load("s3tables", props)
.await
.map_err(|e| Error::Storage(e.to_string()))?,
);
let cold = Arc::new(IcebergCold::new(
Arc::clone(&catalog),
NamespaceIdent::new(self.namespace.to_string()),
self.file_target_bytes,
));
Ok(ColdTier { cold, catalog })
}
}
pub struct ColdTier {
cold: Arc<IcebergCold>,
#[cfg_attr(not(feature = "catalog-facade"), allow(dead_code))]
catalog: Arc<dyn Catalog>,
}
impl std::fmt::Debug for ColdTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ColdTier")
.field("cold", &self.cold)
.finish_non_exhaustive()
}
}
impl ColdTier {
#[must_use]
pub fn cold(&self) -> Arc<IcebergCold> {
Arc::clone(&self.cold)
}
#[cfg(feature = "catalog-facade")]
#[must_use]
pub fn catalog_facade(&self) -> crate::serve::CatalogFacade {
crate::serve::CatalogFacade::new(Arc::clone(&self.catalog))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scheme_defaults_to_file_when_absent() {
assert_eq!(warehouse_scheme("/var/lib/warehouse"), "file");
assert_eq!(warehouse_scheme("file:///tmp/wh"), "file");
assert_eq!(warehouse_scheme("s3://bucket/prefix"), "s3");
assert_eq!(warehouse_scheme("gs://bucket"), "gs");
assert_eq!(warehouse_scheme("memory://"), "memory");
}
#[test]
fn the_always_on_backends_build_without_a_feature() {
assert!(warehouse_factory("file:///tmp/wh").is_ok());
assert!(warehouse_factory("memory://").is_ok());
}
#[test]
fn an_unknown_scheme_is_an_error_not_a_local_fallback() {
let err = warehouse_factory("ftp://host/wh").unwrap_err().to_string();
assert!(err.contains("ftp"), "{err}");
}
#[test]
fn auth_props_are_injected_only_for_s3_schemes() {
use iceberg::io::{S3_ACCESS_KEY_ID, S3_ENDPOINT, S3_PATH_STYLE_ACCESS, S3_REGION};
let auth = WarehouseAuth {
region: Some("eu-central-1".into()),
endpoint: Some("http://minio:9000".into()),
access_key_id: Some("AK".into()),
secret_access_key: Some("SK".into()),
};
let mut s3 = HashMap::new();
apply_warehouse_auth(&mut s3, "s3://bucket", &auth);
assert_eq!(s3.get(S3_REGION).map(String::as_str), Some("eu-central-1"));
assert_eq!(
s3.get(S3_ENDPOINT).map(String::as_str),
Some("http://minio:9000")
);
assert_eq!(
s3.get(S3_PATH_STYLE_ACCESS).map(String::as_str),
Some("true")
);
assert!(s3.contains_key(S3_ACCESS_KEY_ID));
let mut file = HashMap::new();
apply_warehouse_auth(&mut file, "file:///tmp/wh", &auth);
assert!(file.is_empty(), "no S3 props for a file warehouse");
}
#[cfg(feature = "s3tables")]
#[tokio::test]
async fn a_table_bucket_uri_is_rejected_as_the_arn_it_is_not() {
let err = S3TablesCatalog {
table_bucket_arn: "s3://edm/warehouse",
namespace: "metering",
file_target_bytes: 1024,
endpoint_url: None,
region: None,
}
.build()
.await
.expect_err("a URI is not an ARN");
let msg = err.to_string();
assert!(
msg.contains("arn:aws:s3tables:"),
"must name the shape: {msg}"
);
}
}