Skip to main content

laminar_core/checkpoint/object_store_builder/
mod.rs

1//! Factory for building `ObjectStore` instances from URL schemes.
2//!
3//! Provider selection, credentials, retries, and client construction are
4//! delegated to `object_store`. Explicit options override environment values.
5
6#![allow(clippy::disallowed_types)] // cold path: object store setup
7
8use std::collections::HashMap;
9#[cfg(any(feature = "aws", feature = "gcs", feature = "azure"))]
10use std::hash::Hash;
11use std::path::PathBuf;
12#[cfg(any(feature = "aws", feature = "gcs", feature = "azure"))]
13use std::str::FromStr;
14use std::sync::Arc;
15
16#[cfg(feature = "gcs")]
17use crate::gcs_credentials::{configure_gcs_workload_identity, gcs_workload_identity_provider};
18use crate::storage_auth::{classify_storage_auth_source, AuthSource};
19use crate::storage_location::{StorageConsumer, StorageLocation, StorageProvider};
20use object_store::ObjectStore;
21
22/// Errors from object store construction.
23#[derive(Debug, thiserror::Error)]
24pub enum ObjectStoreBuilderError {
25    /// The URL could not be parsed.
26    #[error("invalid object store URL: {0}")]
27    InvalidUrl(String),
28
29    /// Backend construction failed.
30    #[error("object store build error: {0}")]
31    Build(String),
32}
33
34impl From<object_store::Error> for ObjectStoreBuilderError {
35    fn from(e: object_store::Error) -> Self {
36        Self::Build(e.to_string())
37    }
38}
39
40/// Failure domain of a checkpoint object-store URL.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum CheckpointStorageScope {
43    /// No durable checkpoint namespace can be proven from the URL.
44    Volatile,
45    /// A local filesystem namespace survives process restart on one node.
46    NodeDurable,
47    /// A remote object-store namespace is shared by cluster participants.
48    ClusterShared,
49}
50
51impl CheckpointStorageScope {
52    /// Classify only URL schemes accepted by the object-store builder.
53    #[must_use]
54    pub fn for_url(url: &str) -> Self {
55        let Ok(location) = StorageLocation::parse(url) else {
56            return Self::Volatile;
57        };
58        match location.provider {
59            StorageProvider::Local => Self::NodeDurable,
60            StorageProvider::AwsS3 | StorageProvider::AzureAdls | StorageProvider::Gcs => {
61                Self::ClusterShared
62            }
63        }
64    }
65
66    /// Whether this failure domain is at least as durable as the requirement.
67    #[must_use]
68    pub const fn satisfies(self, required: Self) -> bool {
69        self as u8 >= required as u8
70    }
71}
72
73/// Build an [`ObjectStore`] from a URL and optional configuration overrides.
74///
75/// # Supported schemes
76///
77/// | Scheme | Feature |
78/// |--------|---------|
79/// | `file://` | (always) |
80/// | `s3://`, `s3a://` | `aws` |
81/// | `gs://`, `gcs://` | `gcs` |
82/// | `az://`, `abfs://`, `abfss://`, `wasb://`, `wasbs://` | `azure` |
83///
84/// The URL path is applied as a key prefix on the returned store. R2 and
85/// `MinIO` use the S3 scheme with the endpoint option supported by
86/// `object_store`.
87///
88/// # Errors
89///
90/// Returns [`ObjectStoreBuilderError`] if the scheme is unsupported, requires
91/// an uncompiled feature, or the backend fails to build.
92#[allow(clippy::implicit_hasher)]
93pub fn build_object_store(
94    url: &str,
95    options: &HashMap<String, String>,
96) -> Result<Arc<dyn ObjectStore>, ObjectStoreBuilderError> {
97    let mut location = StorageLocation::parse(url)
98        .map_err(|error| ObjectStoreBuilderError::InvalidUrl(error.to_string()))?;
99    if location.provider == StorageProvider::Local {
100        return build_local_file_system(url);
101    }
102    let mut environment = provider_environment(location.provider);
103    if let Some(endpoint) = configured_endpoint(location.provider, options, &environment) {
104        location = location
105            .with_endpoint_override(endpoint)
106            .map_err(|error| ObjectStoreBuilderError::Build(error.to_string()))?;
107    }
108    let adapted = location
109        .adapt(StorageConsumer::ObjectStore)
110        .map_err(|error| ObjectStoreBuilderError::InvalidUrl(error.to_string()))?;
111    let parsed = url::Url::parse(&adapted.url)
112        .map_err(|error| ObjectStoreBuilderError::InvalidUrl(error.to_string()))?;
113    #[cfg(any(feature = "aws", feature = "gcs", feature = "azure"))]
114    validate_explicit_options(location.provider, options)?;
115    #[cfg(feature = "azure")]
116    validate_url_derived_azure_options(options, &adapted.derived_options)?;
117
118    environment.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
119    let auth_source = checkpoint_auth_source(location.provider, options);
120    #[cfg(feature = "gcs")]
121    let workload_identity = if location.provider == StorageProvider::Gcs {
122        gcs_workload_identity_provider(options, &|name| std::env::var(name).ok())
123            .map_err(|error| ObjectStoreBuilderError::Build(error.to_string()))?
124    } else {
125        None
126    };
127    #[cfg(feature = "gcs")]
128    let auth_source = if workload_identity.is_some() {
129        AuthSource::WorkloadIdentity
130    } else {
131        auth_source
132    };
133    let endpoint_transport = location
134        .endpoint_override
135        .as_ref()
136        .map_or("default", |endpoint| endpoint.scheme());
137    tracing::debug!(
138        operation_class = "client-build",
139        provider = location.provider.name(),
140        endpoint_class = location.endpoint_class().name(),
141        endpoint_transport,
142        allow_http = configured_allow_http(location.provider, options, &environment),
143        auth_source = %auth_source,
144        "building checkpoint object-store client"
145    );
146
147    // `parse_url_opts` applies options in iteration order. Keep explicit settings after the
148    // environment so aliases that resolve to the same provider key have deterministic precedence.
149    let resolved = environment
150        .into_iter()
151        .chain(
152            options
153                .iter()
154                .map(|(key, value)| (key.clone(), value.clone())),
155        )
156        .chain(adapted.derived_options)
157        .collect::<Vec<_>>();
158    #[cfg(feature = "gcs")]
159    let result = if let Some(credentials) = workload_identity {
160        build_gcs_workload_identity_store(&parsed, resolved, credentials)
161    } else {
162        object_store::parse_url_opts(&parsed, resolved)
163    };
164    #[cfg(not(feature = "gcs"))]
165    let result = object_store::parse_url_opts(&parsed, resolved);
166    let (store, prefix) = result.map_err(|_| {
167        ObjectStoreBuilderError::Build(format!(
168            "failed to construct the {} client; verify the configured option values and downstream credential chain",
169            location.provider.name()
170        ))
171    })?;
172    let store: Arc<dyn ObjectStore> = Arc::from(store);
173    if prefix.as_ref().is_empty() {
174        Ok(store)
175    } else {
176        Ok(Arc::new(object_store::prefix::PrefixStore::new(
177            store, prefix,
178        )))
179    }
180}
181
182#[cfg(feature = "gcs")]
183fn build_gcs_workload_identity_store(
184    url: &url::Url,
185    options: Vec<(String, String)>,
186    credentials: object_store::gcp::GcpCredentialProvider,
187) -> object_store::Result<(Box<dyn ObjectStore>, object_store::path::Path)> {
188    let builder = object_store::gcp::GoogleCloudStorageBuilder::new().with_url(url.to_string());
189    let store = configure_gcs_workload_identity(builder, options, credentials).build()?;
190    let (_, prefix) = object_store::ObjectStoreScheme::parse(url)?;
191    let prefix = object_store::path::Path::parse(prefix)?;
192    Ok((Box::new(store), prefix))
193}
194
195/// Open the crash-durable local object store used by checkpoint data and metadata.
196///
197/// # Errors
198///
199/// Returns an object-store error when the root cannot be created, synchronized, or opened.
200pub fn durable_local_object_store(
201    root: impl AsRef<std::path::Path>,
202) -> object_store::Result<Arc<dyn ObjectStore>> {
203    Ok(Arc::new(
204        crate::durable_local_store::DurableLocalObjectStore::new(root)?,
205    ))
206}
207
208fn provider_environment_key(provider: StorageProvider, key: &str) -> bool {
209    match provider {
210        StorageProvider::AwsS3 => key.starts_with("AWS_"),
211        StorageProvider::Gcs => key.starts_with("GOOGLE_") || key == "SERVICE_ACCOUNT",
212        StorageProvider::AzureAdls => key.starts_with("AZURE_") || key == "IDENTITY_ENDPOINT",
213        StorageProvider::Local => false,
214    }
215}
216
217fn provider_environment(provider: StorageProvider) -> Vec<(String, String)> {
218    std::env::vars_os()
219        .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
220        .filter(|(key, _)| provider_environment_key(provider, key))
221        .collect()
222}
223
224fn checkpoint_auth_source(
225    provider: StorageProvider,
226    options: &HashMap<String, String>,
227) -> AuthSource {
228    let classified =
229        classify_storage_auth_source(provider, options, &|name| std::env::var(name).ok());
230    if provider == StorageProvider::AwsS3 && classified == AuthSource::Profile {
231        // COMPAT: object_store 0.13 does not load the AWS shared profile files. Delta's AWS
232        // client does, so the shared classifier retains Profile for connector diagnostics.
233        AuthSource::Unknown
234    } else {
235        classified
236    }
237}
238
239fn configured_endpoint<'a>(
240    provider: StorageProvider,
241    options: &'a HashMap<String, String>,
242    environment: &'a [(String, String)],
243) -> Option<&'a str> {
244    let (option_keys, environment_keys): (&[&str], &[&str]) = match provider {
245        StorageProvider::AwsS3 => (
246            &[
247                "aws_endpoint_url_s3",
248                "aws_endpoint",
249                "aws_endpoint_url",
250                "endpoint",
251                "endpoint_url",
252            ],
253            &["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
254        ),
255        StorageProvider::AzureAdls => (
256            &["azure_storage_endpoint", "azure_endpoint", "endpoint"],
257            &["AZURE_STORAGE_ENDPOINT"],
258        ),
259        StorageProvider::Gcs => (
260            &["google_base_url", "base_url"],
261            &["GOOGLE_BASE_URL", "GOOGLE_ENDPOINT_URL"],
262        ),
263        StorageProvider::Local => (&[], &[]),
264    };
265    configured_option(options, option_keys)
266        .or_else(|| configured_environment(environment, environment_keys))
267}
268
269fn configured_allow_http(
270    provider: StorageProvider,
271    options: &HashMap<String, String>,
272    environment: &[(String, String)],
273) -> bool {
274    let (option_keys, environment_keys): (&[&str], &[&str]) = match provider {
275        StorageProvider::AwsS3 => (&["aws_allow_http", "allow_http"], &["AWS_ALLOW_HTTP"]),
276        StorageProvider::AzureAdls => (&["azure_allow_http", "allow_http"], &["AZURE_ALLOW_HTTP"]),
277        StorageProvider::Gcs => (&["google_allow_http", "allow_http"], &["GOOGLE_ALLOW_HTTP"]),
278        StorageProvider::Local => (&[], &[]),
279    };
280    configured_option(options, option_keys)
281        .or_else(|| configured_environment(environment, environment_keys))
282        .is_some_and(|value| value.trim().eq_ignore_ascii_case("true"))
283}
284
285fn configured_option<'a>(options: &'a HashMap<String, String>, keys: &[&str]) -> Option<&'a str> {
286    keys.iter().find_map(|key| {
287        options.iter().find_map(|(candidate, value)| {
288            (candidate.eq_ignore_ascii_case(key) && !value.trim().is_empty())
289                .then_some(value.as_str())
290        })
291    })
292}
293
294fn configured_environment<'a>(
295    environment: &'a [(String, String)],
296    keys: &[&str],
297) -> Option<&'a str> {
298    keys.iter().find_map(|key| {
299        environment.iter().find_map(|(candidate, value)| {
300            (candidate == key && !value.trim().is_empty()).then_some(value.as_str())
301        })
302    })
303}
304
305#[cfg(any(feature = "aws", feature = "gcs", feature = "azure"))]
306fn validate_explicit_options(
307    provider: StorageProvider,
308    options: &HashMap<String, String>,
309) -> Result<(), ObjectStoreBuilderError> {
310    match provider {
311        #[cfg(feature = "aws")]
312        StorageProvider::AwsS3 => {
313            validate_typed_options::<object_store::aws::AmazonS3ConfigKey>("S3", options)
314        }
315        #[cfg(feature = "gcs")]
316        StorageProvider::Gcs => {
317            validate_typed_options::<object_store::gcp::GoogleConfigKey>("GCS", options)
318        }
319        #[cfg(feature = "azure")]
320        StorageProvider::AzureAdls => {
321            validate_typed_options::<object_store::azure::AzureConfigKey>("Azure", options)
322        }
323        _ => Ok(()),
324    }
325}
326
327#[cfg(any(feature = "aws", feature = "gcs", feature = "azure"))]
328fn validate_typed_options<K>(
329    provider: &str,
330    options: &HashMap<String, String>,
331) -> Result<(), ObjectStoreBuilderError>
332where
333    K: FromStr + Eq + Hash,
334    K::Err: std::fmt::Display,
335{
336    let mut canonical = HashMap::<K, &str>::with_capacity(options.len());
337    for key in options.keys() {
338        let parsed = key.to_ascii_lowercase().parse::<K>().map_err(|error| {
339            ObjectStoreBuilderError::Build(format!(
340                "invalid {provider} storage option '{key}': {error}"
341            ))
342        })?;
343        if let Some(previous) = canonical.insert(parsed, key) {
344            return Err(ObjectStoreBuilderError::Build(format!(
345                "{provider} storage options '{previous}' and '{key}' configure the same setting"
346            )));
347        }
348    }
349    Ok(())
350}
351
352#[cfg(feature = "azure")]
353fn validate_url_derived_azure_options(
354    explicit: &HashMap<String, String>,
355    derived: &[(String, String)],
356) -> Result<(), ObjectStoreBuilderError> {
357    use object_store::azure::AzureConfigKey;
358
359    for (derived_key, derived_value) in derived {
360        let canonical = derived_key
361            .parse::<AzureConfigKey>()
362            .map_err(|error| ObjectStoreBuilderError::Build(error.to_string()))?;
363        for (explicit_key, explicit_value) in explicit {
364            let explicit_canonical = explicit_key
365                .to_ascii_lowercase()
366                .parse::<AzureConfigKey>()
367                .map_err(|error| ObjectStoreBuilderError::Build(error.to_string()))?;
368            if explicit_canonical == canonical && explicit_value != derived_value {
369                return Err(ObjectStoreBuilderError::Build(format!(
370                    "Azure storage option '{explicit_key}' conflicts with the URL authority"
371                )));
372            }
373        }
374    }
375    Ok(())
376}
377
378/// Absolute local filesystem path from a canonical lowercase `file://` URL.
379///
380/// # Errors
381/// Returns [`ObjectStoreBuilderError::InvalidUrl`] for a non-file scheme,
382/// remote authority, relative path, query, fragment, or invalid encoding.
383pub fn file_url_path(url: &str) -> Result<PathBuf, ObjectStoreBuilderError> {
384    let location = StorageLocation::parse(url)
385        .map_err(|error| ObjectStoreBuilderError::InvalidUrl(error.to_string()))?;
386    if location.provider != StorageProvider::Local {
387        return Err(ObjectStoreBuilderError::InvalidUrl(
388            "expected a file URL".to_string(),
389        ));
390    }
391    let adapted = location
392        .adapt(StorageConsumer::ObjectStore)
393        .map_err(|error| ObjectStoreBuilderError::InvalidUrl(error.to_string()))?;
394    let parsed = url::Url::parse(&adapted.url)
395        .map_err(|error| ObjectStoreBuilderError::InvalidUrl(error.to_string()))?;
396    let path = parsed.to_file_path().map_err(|()| {
397        ObjectStoreBuilderError::InvalidUrl("file URL is not a local filesystem path".to_string())
398    })?;
399    if !path.is_absolute() {
400        return Err(ObjectStoreBuilderError::InvalidUrl(
401            "file URL path must be absolute".to_string(),
402        ));
403    }
404    Ok(path)
405}
406
407/// Whether a file URL syntactically names an absolute local namespace.
408///
409/// This failure-domain classification is deliberately independent of the current host's path
410/// syntax. Actual store construction still calls [`file_url_path`] and rejects paths the current
411/// operating system cannot represent before any runtime starts.
412#[must_use]
413pub fn is_absolute_local_file_url(url: &str) -> bool {
414    StorageLocation::parse(url).is_ok_and(|location| location.provider == StorageProvider::Local)
415}
416
417/// Extract the local path from a `file://` URL and create a crash-durable local store.
418fn build_local_file_system(url: &str) -> Result<Arc<dyn ObjectStore>, ObjectStoreBuilderError> {
419    let path = file_url_path(url)?;
420    durable_local_object_store(path).map_err(Into::into)
421}
422
423#[cfg(test)]
424mod tests;