1#![allow(clippy::disallowed_types)] use 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#[derive(Debug, thiserror::Error)]
24pub enum ObjectStoreBuilderError {
25 #[error("invalid object store URL: {0}")]
27 InvalidUrl(String),
28
29 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum CheckpointStorageScope {
43 Volatile,
45 NodeDurable,
47 ClusterShared,
49}
50
51impl CheckpointStorageScope {
52 #[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 #[must_use]
68 pub const fn satisfies(self, required: Self) -> bool {
69 self as u8 >= required as u8
70 }
71}
72
73#[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 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
195pub 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 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
378pub 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#[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
417fn 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;