use std::sync::Arc;
use object_store::aws::AmazonS3Builder;
use object_store::azure::MicrosoftAzureBuilder;
use object_store::client::SpawnedReqwestConnector;
use object_store::gcp::GoogleCloudStorageBuilder;
use object_store::local::LocalFileSystem;
use object_store::path::Path;
use object_store::prefix::PrefixStore;
use object_store::{ObjectStore, Result};
use olai_http::CloudClient;
use tokio::runtime::Handle;
use unitycatalog_client::{TemporaryCredentialClient, UnityCatalogClient};
use unitycatalog_common::tables::v1::GetTableRequest;
use unitycatalog_common::temporary_credentials::v1::TemporaryCredential;
use unitycatalog_common::volumes::v1::GetVolumeRequest;
use url::Url;
use crate::credential::{
SecurableRef, as_aws, as_azure, as_gcp, aws_access_point, new_aws, new_azure, new_gcp,
};
pub use crate::error::Error;
pub use unitycatalog_common::UCReference;
pub use unitycatalog_client::{
PathOperation, TableOperation, TableReference, VolumeOperation, VolumeReference,
};
mod credential;
mod error;
#[derive(Debug, Clone, Default)]
pub struct UnityObjectStoreFactoryBuilder {
uri: Option<String>,
token: Option<String>,
allow_unauthenticated: bool,
aws_region: Option<String>,
io_handle: Option<Handle>,
}
impl UnityObjectStoreFactoryBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
self.uri = Some(uri.into());
self
}
pub fn with_token(mut self, token: impl Into<Option<String>>) -> Self {
self.token = token.into();
self
}
pub fn with_allow_unauthenticated(mut self, allow_unauthenticated: bool) -> Self {
self.allow_unauthenticated = allow_unauthenticated;
self
}
pub fn with_aws_region(mut self, aws_region: impl Into<Option<String>>) -> Self {
self.aws_region = aws_region.into();
self
}
pub fn with_io_runtime(mut self, handle: impl Into<Option<Handle>>) -> Self {
self.io_handle = handle.into();
self
}
pub async fn build(self) -> Result<UnityObjectStoreFactory> {
let url = if let Some(uri) = self.uri {
url::Url::parse(&uri).map_err(Error::from)?
} else {
return Err(Error::invalid_config("missing `uri` for Unity Catalog endpoint").into());
};
let cloud_client = if let Some(token) = self.token {
CloudClient::new_with_token(token)
} else if self.allow_unauthenticated {
CloudClient::new_unauthenticated()
} else {
return Err(Error::invalid_config(
"no token and `allow_unauthenticated` not set: cannot build credential client",
)
.into());
};
let cloud_client = match &self.io_handle {
Some(handle) => cloud_client.with_runtime(handle.clone()),
None => cloud_client,
};
let creds = TemporaryCredentialClient::new_with_url(cloud_client.clone(), url.clone());
let uc = UnityCatalogClient::new(cloud_client, url);
Ok(UnityObjectStoreFactory {
creds,
uc,
aws_region: self.aws_region,
io_handle: self.io_handle,
})
}
}
#[derive(Clone)]
pub struct UCStore {
root: Arc<dyn ObjectStore>,
url: Url,
path: Path,
}
impl UCStore {
pub fn as_dyn(&self) -> Arc<dyn ObjectStore> {
if self.path.as_ref().is_empty() {
self.root.clone()
} else {
Arc::new(PrefixStore::new(self.root.clone(), self.path.clone()))
}
}
pub fn root(&self) -> Arc<dyn ObjectStore> {
self.root.clone()
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn prefix(&self) -> &Path {
&self.path
}
}
#[derive(Clone)]
pub struct UnityObjectStoreFactory {
creds: TemporaryCredentialClient,
uc: UnityCatalogClient,
aws_region: Option<String>,
io_handle: Option<Handle>,
}
impl UnityObjectStoreFactory {
pub fn builder() -> UnityObjectStoreFactoryBuilder {
UnityObjectStoreFactoryBuilder::default()
}
pub fn unity_client(&self) -> &UnityCatalogClient {
&self.uc
}
pub fn credentials_client(&self) -> &TemporaryCredentialClient {
&self.creds
}
pub async fn for_url(&self, url: &str, op: Operation) -> Result<UCStore> {
let reference = UCReference::parse(url)
.map_err(crate::error::Error::from)
.map_err(object_store::Error::from)?;
match reference {
UCReference::Volume {
catalog,
schema,
volume,
path,
} => {
let name = format!("{catalog}.{schema}.{volume}");
let store = self.for_volume(name, op.into_volume()).await?;
if path.is_empty() {
Ok(store)
} else {
Ok(extend_prefix(store, &path))
}
}
UCReference::Table {
catalog,
schema,
table,
} => {
let name = format!("{catalog}.{schema}.{table}");
self.for_table(name, op.into_table()).await
}
UCReference::Path(url) => self.for_path(&url, op.into_path()).await,
}
}
pub async fn for_table(
&self,
table: impl Into<TableReference>,
operation: TableOperation,
) -> Result<UCStore> {
let table = table.into();
if let TableReference::Name(name) = &table
&& let Some(location) = self.table_storage_location(name).await?
&& let Ok(url) = Url::parse(&location)
&& url.scheme() == "file"
{
let path_op = match operation {
TableOperation::Read => PathOperation::Read,
TableOperation::ReadWrite => PathOperation::ReadWrite,
};
return local_store(&url, path_op);
}
let (credential, table_id) = self
.creds
.temporary_table_credential(table, operation)
.await
.map_err(Error::from)?;
let securable = SecurableRef::Table(table_id, operation);
self.build_store(credential, securable).await
}
async fn table_storage_location(&self, full_name: &str) -> Result<Option<String>> {
let table = self
.uc
.tables_client()
.get_table(&GetTableRequest {
full_name: full_name.to_string(),
include_browse: Some(false),
include_delta_metadata: Some(false),
include_manifest_capabilities: Some(false),
..Default::default()
})
.await
.map_err(Error::from)?;
Ok(table.storage_location.filter(|s| !s.is_empty()))
}
pub async fn for_volume(
&self,
volume: impl Into<VolumeReference>,
operation: VolumeOperation,
) -> Result<UCStore> {
let volume = volume.into();
if let VolumeReference::Name(name) = &volume
&& let Some(location) = self.volume_storage_location(name).await?
&& let Ok(url) = Url::parse(&location)
&& url.scheme() == "file"
{
let path_op = match operation {
VolumeOperation::Read => PathOperation::Read,
VolumeOperation::ReadWrite => PathOperation::ReadWrite,
};
return local_store(&url, path_op);
}
let (credential, volume_id) = self
.creds
.temporary_volume_credential(volume, operation)
.await
.map_err(Error::from)?;
let securable = SecurableRef::Volume(volume_id, operation);
self.build_store(credential, securable).await
}
async fn volume_storage_location(&self, name: &str) -> Result<Option<String>> {
let volume = self
.uc
.volumes_client()
.get_volume(&GetVolumeRequest {
name: name.to_string(),
include_browse: Some(false),
..Default::default()
})
.await
.map_err(Error::from)?;
Ok(Some(volume.storage_location).filter(|s| !s.is_empty()))
}
pub async fn for_path(&self, path: &Url, operation: PathOperation) -> Result<UCStore> {
if path.scheme() == "file" {
return local_store(path, operation);
}
let (credential, _resolved) = self
.creds
.temporary_path_credential(path.clone(), operation, false)
.await
.map_err(Error::from)?;
let securable = SecurableRef::Path(path.clone(), operation, Some(false));
self.build_store(credential, securable).await
}
pub async fn dry_run_path(&self, path: &Url, operation: PathOperation) -> Result<UCStore> {
if path.scheme() == "file" {
return local_store(path, operation);
}
let (credential, _resolved) = self
.creds
.temporary_path_credential(path.clone(), operation, true)
.await
.map_err(Error::from)?;
let securable = SecurableRef::Path(path.clone(), operation, Some(true));
self.build_store(credential, securable).await
}
async fn build_store(
&self,
credential: TemporaryCredential,
securable: SecurableRef,
) -> Result<UCStore> {
let url = Url::parse(&credential.url).map_err(Error::from)?;
let path = match parse_azurite(&url) {
Some(loc) => Path::from(loc.prefix),
None => Path::from_url_path(url.path())?,
};
let store = self.to_store(credential, securable).await?;
Ok(UCStore {
root: store,
url,
path,
})
}
async fn to_store(
&self,
credential: TemporaryCredential,
securable: SecurableRef,
) -> Result<Arc<dyn ObjectStore>> {
if as_azure(&credential).is_ok() {
let url = Url::parse(&credential.url).map_err(Error::from)?;
if let Some(loc) = parse_azurite(&url) {
let sas = azure_sas_token(&credential).ok_or_else(|| {
Error::invalid_config(
"Azurite store requires a SAS-token credential".to_string(),
)
})?;
let mut builder = MicrosoftAzureBuilder::new()
.with_use_emulator(true)
.with_account(loc.account)
.with_container_name(loc.container)
.with_config(object_store::azure::AzureConfigKey::SasKey, sas);
if let Some(handle) = &self.io_handle {
builder =
builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
}
let store = builder.build()?;
return Ok(Arc::new(store));
}
let provider = new_azure(self.creds.clone(), &credential, securable).await?;
let mut builder = MicrosoftAzureBuilder::new()
.with_url(url.to_string())
.with_credentials(Arc::new(provider));
if let Some(handle) = &self.io_handle {
builder = builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
}
let store = builder.build()?;
return Ok(Arc::new(store));
}
if as_aws(&credential).is_ok() {
let access_point = aws_access_point(&credential);
let provider = new_aws(self.creds.clone(), &credential, securable).await?;
let url = Url::parse(&credential.url).map_err(Error::from)?;
let mut builder = AmazonS3Builder::new()
.with_url(url.to_string())
.with_credentials(Arc::new(provider));
if let Some(region) = self
.aws_region
.clone()
.or_else(|| std::env::var("AWS_REGION").ok())
{
builder = builder.with_region(region);
}
if let Some(ap) = access_point {
builder = builder.with_bucket_name(ap);
}
if let Some(handle) = &self.io_handle {
builder = builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
}
let store = builder.build()?;
return Ok(Arc::new(store));
}
if as_gcp(&credential).is_ok() {
let provider = new_gcp(self.creds.clone(), &credential, securable).await?;
let url = Url::parse(&credential.url).map_err(Error::from)?;
let mut builder = GoogleCloudStorageBuilder::new()
.with_url(url.to_string())
.with_credentials(Arc::new(provider));
if let Some(handle) = &self.io_handle {
builder = builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
}
let store = builder.build()?;
return Ok(Arc::new(store));
}
Err(
Error::InvalidCredential("Failed to match credential with storage type".to_string())
.into(),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Read,
ReadWrite,
}
impl Operation {
fn into_volume(self) -> VolumeOperation {
match self {
Operation::Read => VolumeOperation::Read,
Operation::ReadWrite => VolumeOperation::ReadWrite,
}
}
fn into_table(self) -> TableOperation {
match self {
Operation::Read => TableOperation::Read,
Operation::ReadWrite => TableOperation::ReadWrite,
}
}
fn into_path(self) -> PathOperation {
match self {
Operation::Read => PathOperation::Read,
Operation::ReadWrite => PathOperation::ReadWrite,
}
}
}
impl From<Operation> for TableOperation {
fn from(op: Operation) -> Self {
op.into_table()
}
}
impl From<Operation> for VolumeOperation {
fn from(op: Operation) -> Self {
op.into_volume()
}
}
impl From<Operation> for PathOperation {
fn from(op: Operation) -> Self {
op.into_path()
}
}
fn local_store(url: &Url, operation: PathOperation) -> Result<UCStore> {
if cfg!(windows) {
return Err(Error::invalid_config(format!(
"local (file://) storage is not supported on Windows: {url}"
))
.into());
}
let dir = url
.to_file_path()
.map_err(|_| Error::invalid_url(format!("not a valid local file path: {url}")))?;
if matches!(
operation,
PathOperation::ReadWrite | PathOperation::CreateTable
) {
std::fs::create_dir_all(&dir).map_err(|e| {
Error::invalid_config(format!("failed to create local directory {dir:?}: {e}"))
})?;
}
let store = LocalFileSystem::new();
let path = Path::from_url_path(url.path())?;
Ok(UCStore {
root: Arc::new(store),
url: url.clone(),
path,
})
}
struct AzuriteLocation {
account: String,
container: String,
prefix: String,
}
fn parse_azurite(url: &Url) -> Option<AzuriteLocation> {
const EMULATOR_ACCOUNT: &str = "devstoreaccount1";
if url.scheme() == "azurite" {
let container = url.host_str().filter(|s| !s.is_empty())?.to_owned();
let prefix = url.path().trim_start_matches('/').to_owned();
return Some(AzuriteLocation {
account: EMULATOR_ACCOUNT.to_owned(),
container,
prefix,
});
}
let is_localhost = matches!(url.host_str(), Some("localhost") | Some("127.0.0.1"));
if url.scheme() == "http" && is_localhost && url.port() == Some(10000) {
let mut segments = url.path_segments()?;
let account = segments.next().filter(|s| !s.is_empty())?.to_owned();
let container = segments.next().filter(|s| !s.is_empty())?.to_owned();
let prefix = segments.collect::<Vec<_>>().join("/");
return Some(AzuriteLocation {
account,
container,
prefix,
});
}
None
}
fn azure_sas_token(credential: &TemporaryCredential) -> Option<String> {
use unitycatalog_common::temporary_credentials::v1::temporary_credential::Credentials;
match credential.credentials.as_ref()? {
Credentials::AzureUserDelegationSas(sas) => Some(sas.sas_token.clone()),
_ => None,
}
}
fn extend_prefix(store: UCStore, extra: &str) -> UCStore {
let mut url = store.url.clone();
{
let mut segs = url.path_segments_mut().expect("cloud URL has a path");
segs.pop_if_empty();
for part in extra.split('/').filter(|p| !p.is_empty()) {
segs.push(part);
}
}
let new_path = if store.path.as_ref().is_empty() {
Path::from(extra)
} else {
let base = store.path.as_ref().trim_end_matches('/');
let extra = extra.trim_start_matches('/');
Path::from(format!("{base}/{extra}"))
};
UCStore {
root: store.root,
url,
path: new_path,
}
}
#[cfg(test)]
mod tests {
use futures::TryStreamExt;
#[cfg(not(windows))]
use object_store::{ObjectStoreExt, PutPayload};
use super::*;
async fn offline_factory() -> UnityObjectStoreFactory {
UnityObjectStoreFactory::builder()
.with_uri("http://127.0.0.1:0/api/2.1/unity-catalog/")
.with_allow_unauthenticated(true)
.build()
.await
.unwrap()
}
#[cfg(not(windows))]
#[tokio::test]
async fn for_url_file_roundtrips_without_vending() {
let dir = tempfile::tempdir().unwrap();
let url = Url::from_directory_path(dir.path()).unwrap();
let factory = offline_factory().await;
let store = factory
.for_url(url.as_str(), Operation::ReadWrite)
.await
.unwrap();
let dyn_store = store.as_dyn();
let path = object_store::path::Path::from("hello.txt");
dyn_store
.put(&path, PutPayload::from_static(b"world"))
.await
.unwrap();
let listing: Vec<_> = dyn_store.list(None).try_collect().await.unwrap();
assert_eq!(listing.len(), 1, "expected exactly one object");
assert_eq!(listing[0].location, path);
let got = dyn_store.get(&path).await.unwrap().bytes().await.unwrap();
assert_eq!(&got[..], b"world");
}
#[cfg(not(windows))]
#[tokio::test]
async fn for_path_file_skips_vending() {
let dir = tempfile::tempdir().unwrap();
let url = Url::from_directory_path(dir.path()).unwrap();
let factory = offline_factory().await;
let store = factory.for_path(&url, PathOperation::Read).await.unwrap();
let listing: Vec<_> = store.as_dyn().list(None).try_collect().await.unwrap();
assert!(listing.is_empty());
assert_eq!(store.url(), &url);
}
#[cfg(not(windows))]
#[tokio::test]
async fn local_store_read_write_creates_dir() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("not-yet-here");
let url = Url::from_directory_path(&missing).unwrap();
let store = local_store(&url, PathOperation::ReadWrite).unwrap();
assert!(missing.exists(), "ReadWrite must create the root directory");
let path = object_store::path::Path::from("a.bin");
store
.as_dyn()
.put(&path, PutPayload::from_static(b"x"))
.await
.unwrap();
assert!(missing.join("a.bin").exists());
}
#[cfg(not(windows))]
#[tokio::test]
async fn local_store_root_resolves_full_path() {
let dir = tempfile::tempdir().unwrap();
let table_dir = dir.path().join("mytable");
let url = Url::from_directory_path(&table_dir).unwrap();
let store = local_store(&url, PathOperation::ReadWrite).unwrap();
let full = Path::from(format!("{}/part-0.parquet", store.prefix()));
store
.root()
.put(&full, PutPayload::from_static(b"data"))
.await
.unwrap();
let got = store
.root()
.get(&full)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(&got[..], b"data");
let entries: Vec<_> = std::fs::read_dir(&table_dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(entries, vec![std::ffi::OsString::from("part-0.parquet")]);
}
#[cfg(not(windows))]
#[test]
fn local_store_rejects_non_file_url() {
let url = Url::parse("s3://bucket/prefix/").unwrap();
match local_store(&url, PathOperation::Read) {
Ok(_) => panic!("expected an error for a non-file URL"),
Err(e) => assert!(
e.to_string().contains("not a valid local file path"),
"unexpected error: {e}"
),
}
}
#[test]
fn build_with_io_runtime_carries_handle() {
let io_runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let handle = io_runtime.handle().clone();
let driver = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
driver.block_on(async {
let factory = UnityObjectStoreFactory::builder()
.with_uri("http://localhost:8080/api/2.1/unity-catalog/")
.with_allow_unauthenticated(true)
.with_io_runtime(handle.clone())
.build()
.await
.unwrap();
assert!(factory.io_handle.is_some());
let factory = UnityObjectStoreFactory::builder()
.with_uri("http://localhost:8080/api/2.1/unity-catalog/")
.with_allow_unauthenticated(true)
.with_io_runtime(handle.clone())
.with_io_runtime(None)
.build()
.await
.unwrap();
assert!(factory.io_handle.is_none());
});
}
#[test]
#[ignore]
fn list_store_via_temp_credential_on_io_runtime() {
let databricks_host = std::env::var("DATABRICKS_HOST").unwrap();
let databricks_token = std::env::var("DATABRICKS_TOKEN").unwrap();
let io_runtime = std::thread::spawn(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
})
.join()
.unwrap();
let io_handle = io_runtime.handle().clone();
let main_runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
main_runtime.block_on(async move {
let factory = UnityObjectStoreFactory::builder()
.with_uri(format!("{databricks_host}/api/2.1/unity-catalog/"))
.with_token(databricks_token)
.with_aws_region("eu-north-1".to_string())
.with_io_runtime(io_handle)
.build()
.await
.unwrap();
let volume_path = url::Url::parse("s3://open-lakehouse-dev/volumes/").unwrap();
let store = factory
.for_path(&volume_path, PathOperation::Read)
.await
.unwrap();
let files: Vec<_> = store.as_dyn().list(None).try_collect().await.unwrap();
println!("files: {files:?}");
});
}
#[test]
fn parse_azurite_path_style() {
let url =
Url::parse("http://127.0.0.1:10000/devstoreaccount1/mycontainer/some/prefix").unwrap();
let loc = parse_azurite(&url).expect("should parse path-style azurite URL");
assert_eq!(loc.account, "devstoreaccount1");
assert_eq!(loc.container, "mycontainer");
assert_eq!(loc.prefix, "some/prefix");
}
#[test]
fn parse_azurite_custom_scheme() {
let url = Url::parse("azurite://mycontainer/some/prefix").unwrap();
let loc = parse_azurite(&url).expect("should parse azurite:// URL");
assert_eq!(loc.account, "devstoreaccount1");
assert_eq!(loc.container, "mycontainer");
assert_eq!(loc.prefix, "some/prefix");
}
#[test]
fn parse_azurite_localhost_alias() {
let url = Url::parse("http://localhost:10000/acct/cont/p").unwrap();
let loc = parse_azurite(&url).expect("localhost is also an azurite host");
assert_eq!(loc.account, "acct");
assert_eq!(loc.container, "cont");
assert_eq!(loc.prefix, "p");
}
#[test]
fn parse_azurite_rejects_real_cloud_urls() {
for raw in [
"https://acct.blob.core.windows.net/container/path",
"s3://bucket/prefix",
"http://127.0.0.1:9000/acct/cont/p", ] {
let url = Url::parse(raw).unwrap();
assert!(parse_azurite(&url).is_none(), "should not match: {raw}");
}
}
#[tokio::test]
async fn azurite_store_targets_emulator_and_roots_prefix() {
use unitycatalog_common::temporary_credentials::v1::AzureUserDelegationSas;
let url = "http://127.0.0.1:10000/devstoreaccount1/mycontainer/tbl/data";
let credential = TemporaryCredential {
expiration_time: now_epoch_millis() + 3_600_000,
url: url.to_string(),
credentials: Some(
AzureUserDelegationSas {
sas_token: "sv=2021-08-06&ss=b&srt=co&sp=rl&se=2999-01-01T00:00:00Z&sig=AAAA"
.to_string(),
..Default::default()
}
.into(),
),
..Default::default()
};
let factory = offline_factory().await;
let securable =
SecurableRef::Path(Url::parse(url).unwrap(), PathOperation::Read, Some(false));
let store = factory.build_store(credential, securable).await.unwrap();
assert_eq!(store.prefix(), &Path::from("tbl/data"));
}
fn now_epoch_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
}