Skip to main content

krabka_object_store/
build.rs

1//! Construction of an `object_store::ObjectStore` handle from a config.
2
3#[cfg(target_family = "wasm")]
4use crate::unavailable::unavailable;
5use {
6    super::{error::ObjectStoreError, object_store_api as object_store},
7    crate::config::ObjectStoreConfig,
8    object_store::{ObjectStore, memory::InMemory},
9    std::sync::Arc,
10};
11#[cfg(not(target_family = "wasm"))]
12use {
13    crate::config::{GcsConfig, S3Config},
14    object_store::{
15        ClientOptions, RetryConfig,
16        aws::{AmazonS3, AmazonS3Builder, Checksum, S3ConditionalPut},
17        gcp::{GoogleCloudStorage, GoogleCloudStorageBuilder},
18        local::LocalFileSystem,
19    },
20    std::time::Duration,
21};
22
23/// The retry budget `cfg` asks for.
24///
25/// The backoff curve is `object_store`'s: what an operator turns is how many
26/// attempts a request gets and how long the whole sequence may take, not the
27/// shape of the sleeps between them.
28#[cfg(not(target_family = "wasm"))]
29fn retry_config(max_retries: usize, retry_timeout: Duration) -> RetryConfig {
30    RetryConfig {
31        backoff: object_store::BackoffConfig::default(),
32        max_retries,
33        retry_timeout,
34    }
35}
36
37/// The HTTP-client bounds `cfg` asks for, on top of `base`.
38///
39/// Both timeouts are always set rather than left to the crate's defaults: a
40/// store that stalls has to hit a bound the operator chose, and a value that
41/// only exists inside a dependency is not one they can turn.
42#[cfg(not(target_family = "wasm"))]
43fn client_options(
44    base: ClientOptions,
45    allow_http: bool,
46    request_timeout: Duration,
47    connect_timeout: Duration,
48) -> ClientOptions {
49    base.with_allow_http(allow_http)
50        .with_timeout(request_timeout)
51        .with_connect_timeout(connect_timeout)
52}
53
54/// Build an `object_store` handle for `cfg`.
55///
56/// The builder wiring covers the credential chains, the endpoints, and
57/// `allow_http`. It is identical to the wiring in the per-crate constructors.
58///
59/// # Errors
60///
61/// Returns [`ObjectStoreError::InvalidConfig`] if the backend builder rejects
62/// the combination of bucket, region, endpoint, and credentials. On
63/// `wasm32-wasip1`, also for every backend other than
64/// [`ObjectStoreConfig::InMemory`].
65pub fn build_object_store(
66    cfg: &ObjectStoreConfig,
67) -> Result<Arc<dyn ObjectStore>, ObjectStoreError> {
68    match cfg {
69        #[cfg(not(target_family = "wasm"))]
70        ObjectStoreConfig::S3(s3) => build_s3(s3),
71        #[cfg(not(target_family = "wasm"))]
72        ObjectStoreConfig::Gcs(gcs) => Ok(Arc::new(build_gcs_store(gcs)?)),
73        #[cfg(not(target_family = "wasm"))]
74        ObjectStoreConfig::Local { root } => {
75            let store = LocalFileSystem::new_with_prefix(root)
76                .map_err(|e| ObjectStoreError::InvalidConfig(format!("local: {e}")))?;
77            Ok(Arc::new(store))
78        }
79        #[cfg(target_family = "wasm")]
80        ObjectStoreConfig::S3(_) => Err(unavailable("S3")),
81        #[cfg(target_family = "wasm")]
82        ObjectStoreConfig::Gcs(_) => Err(unavailable("GCS")),
83        #[cfg(target_family = "wasm")]
84        ObjectStoreConfig::Local { .. } => Err(unavailable("local filesystem")),
85        ObjectStoreConfig::InMemory => Ok(Arc::new(InMemory::new())),
86    }
87}
88
89#[cfg(not(target_family = "wasm"))]
90pub fn build_s3_store(cfg: &S3Config) -> Result<AmazonS3, ObjectStoreError> {
91    let mut builder = krabka_macros::connection_policy!(
92        AmazonS3Builder::new()
93            .with_bucket_name(&cfg.bucket)
94            .with_region(&cfg.region)
95            .with_allow_http(cfg.allow_http),
96        cfg
97    );
98    if let Some(endpoint) = &cfg.endpoint {
99        builder = builder.with_endpoint(endpoint);
100    }
101    if let (Some(k), Some(s)) = (&cfg.access_key_id, &cfg.secret_access_key) {
102        builder = builder.with_access_key_id(k).with_secret_access_key(s);
103    }
104    if cfg.conditional_put {
105        builder = builder.with_conditional_put(S3ConditionalPut::ETagMatch);
106    }
107    if cfg.checksum_sha256 {
108        builder = builder.with_checksum_algorithm(Checksum::SHA256);
109    }
110    builder
111        .build()
112        .map_err(|e| ObjectStoreError::InvalidConfig(format!("S3 builder: {e}")))
113}
114
115#[cfg(not(target_family = "wasm"))]
116fn build_s3(cfg: &S3Config) -> Result<Arc<dyn ObjectStore>, ObjectStoreError> {
117    Ok(Arc::new(build_s3_store(cfg)?))
118}
119
120#[cfg(not(target_family = "wasm"))]
121pub fn build_gcs_store(cfg: &GcsConfig) -> Result<GoogleCloudStorage, ObjectStoreError> {
122    let mut builder = GoogleCloudStorageBuilder::new().with_bucket_name(&cfg.bucket);
123    if let Some(path) = &cfg.service_account_path {
124        builder = builder.with_service_account_path(path);
125    }
126    if let Some(key) = &cfg.service_account_key {
127        builder = builder.with_service_account_key(key);
128    }
129    if let Some(adc) = &cfg.application_credentials_path {
130        builder = builder.with_application_credentials(adc);
131    }
132    if let Some(endpoint) = &cfg.endpoint {
133        builder = builder.with_base_url(endpoint);
134    }
135    builder = krabka_macros::connection_policy!(builder, cfg);
136    let store = builder
137        .build()
138        .map_err(|e| ObjectStoreError::InvalidConfig(format!("GCS builder: {e}")))?;
139    Ok(store)
140}
141
142#[cfg(test)]
143mod tests {
144    use {
145        super::{object_store, *},
146        crate::{
147            config::{GcsConfig, ObjectStoreConfig, S3Config},
148            test_support::{HttpListener, http_listener, round_trip, s3_config},
149        },
150        assert2::{assert, check},
151        object_store::{ObjectStoreExt as _, path::Path},
152        std::{
153            future,
154            sync::atomic::{AtomicUsize, Ordering},
155            time::{Duration, Instant},
156        },
157    };
158
159    #[test]
160    fn inmemory_builds() {
161        assert!(build_object_store(&ObjectStoreConfig::InMemory).is_ok());
162    }
163
164    #[tokio::test]
165    async fn inmemory_round_trips() {
166        let store = build_object_store(&ObjectStoreConfig::InMemory).unwrap();
167        let path = Path::from("t/x");
168        let got = round_trip(&*store, &path, b"hi").await;
169        assert!(&got[..] == b"hi");
170    }
171
172    #[test]
173    fn local_builds_against_tempdir() {
174        let dir = tempfile::tempdir().unwrap();
175        let cfg = ObjectStoreConfig::Local {
176            root: dir.path().to_path_buf(),
177        };
178        assert!(build_object_store(&cfg).is_ok());
179    }
180
181    #[test]
182    fn s3_builds_with_endpoint_and_allow_http() {
183        let cfg = ObjectStoreConfig::S3(S3Config {
184            bucket: "b".into(),
185            region: "us-east-1".into(),
186            endpoint: Some("http://minio:9000".into()),
187            allow_http: true,
188            ..Default::default()
189        });
190        assert!(build_object_store(&cfg).is_ok());
191    }
192
193    /// The integrity knobs are opt-out. Turning both off must still yield a
194    /// usable builder: the flags select request headers, not a different
195    /// backend.
196    #[test]
197    fn s3_builds_with_integrity_knobs_off() {
198        let cfg = ObjectStoreConfig::S3(S3Config {
199            bucket: "b".into(),
200            region: "us-east-1".into(),
201            conditional_put: false,
202            checksum_sha256: false,
203            ..Default::default()
204        });
205        assert!(build_object_store(&cfg).is_ok());
206    }
207
208    /// The default config turns both knobs on, so the common path must build
209    /// with conditional put and SHA-256 checksums wired in.
210    #[test]
211    fn s3_builds_with_integrity_knobs_on() {
212        let cfg = ObjectStoreConfig::S3(S3Config {
213            bucket: "b".into(),
214            region: "us-east-1".into(),
215            ..Default::default()
216        });
217        assert!(build_object_store(&cfg).is_ok());
218    }
219
220    // Ported from crates/remote-storage/src/gcs.rs tests: with every credential
221    // field None, the builder selects Workload Identity / ADC and constructs.
222    #[test]
223    fn gcs_workload_identity_builds() {
224        let cfg = ObjectStoreConfig::Gcs(GcsConfig {
225            bucket: "b".into(),
226            ..Default::default()
227        });
228        assert!(build_object_store(&cfg).is_ok());
229    }
230
231    // Ported from gcs.rs tests: a custom endpoint + allow_http builds.
232    #[test]
233    fn gcs_honors_endpoint_and_allow_http() {
234        let cfg = ObjectStoreConfig::Gcs(GcsConfig {
235            bucket: "b".into(),
236            endpoint: Some("http://fake-gcs:4443".into()),
237            allow_http: true,
238            ..Default::default()
239        });
240        assert!(build_object_store(&cfg).is_ok());
241    }
242
243    /// A store that accepts the connection and then answers nothing must not
244    /// hang the caller: `request_timeout` is the bound, and it is the operator
245    /// who set it.
246    #[tokio::test]
247    async fn s3_request_timeout_bounds_a_stalled_store() {
248        let HttpListener { listener, endpoint } = http_listener().await;
249        // Hold the accepted sockets open for the length of the test: dropping
250        // them would answer the request with a reset, which is not a stall.
251        let server = tokio::spawn(async move {
252            loop {
253                let Ok((socket, _)) = listener.accept().await else {
254                    return;
255                };
256                tokio::spawn(async move {
257                    let _held = socket;
258                    future::pending::<()>().await;
259                });
260            }
261        });
262
263        let store = build_object_store(&ObjectStoreConfig::S3(S3Config {
264            max_retries: 0,
265            request_timeout: Duration::from_millis(200),
266            ..s3_config("b", endpoint)
267        }))
268        .unwrap();
269
270        let started = Instant::now();
271        let result = store.get(&Path::from("stalled")).await;
272        let elapsed = started.elapsed();
273        server.abort();
274
275        assert!(result.is_err());
276        check!(
277            elapsed < Duration::from_secs(5),
278            "a 200ms request timeout let a stalled store block for {elapsed:?}"
279        );
280    }
281
282    /// `max_retries` is the attempt budget, not a suggestion: a store that
283    /// answers `503 SlowDown` to everything must be given exactly
284    /// `max_retries + 1` attempts before the error reaches the caller.
285    #[tokio::test]
286    async fn s3_max_retries_bounds_the_attempts_against_a_throttling_store() {
287        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
288
289        let HttpListener { listener, endpoint } = http_listener().await;
290        let attempts = Arc::new(AtomicUsize::new(0));
291        let counter = Arc::clone(&attempts);
292        let server = tokio::spawn(async move {
293            loop {
294                let Ok((mut socket, _)) = listener.accept().await else {
295                    return;
296                };
297                let mut request = Vec::new();
298                while !request.ends_with(b"\r\n\r\n") {
299                    let mut chunk = [0; 1024];
300                    match socket.read(&mut chunk).await {
301                        Ok(0) | Err(_) => break,
302                        Ok(read) => request.extend_from_slice(&chunk[..read]),
303                    }
304                }
305                if request.is_empty() {
306                    continue;
307                }
308                counter.fetch_add(1, Ordering::SeqCst);
309                let body = "<Error><Code>SlowDown</Code><Message>Please reduce your \
310                            request rate.</Message></Error>";
311                let response = format!(
312                    "HTTP/1.1 503 Slow Down\r\nContent-Length: {}\r\nConnection: \
313                     close\r\n\r\n{body}",
314                    body.len()
315                );
316                let _ = socket.write_all(response.as_bytes()).await;
317                let _ = socket.flush().await;
318            }
319        });
320
321        let store = build_object_store(&ObjectStoreConfig::S3(S3Config {
322            max_retries: 2,
323            retry_timeout: Duration::from_secs(30),
324            request_timeout: Duration::from_secs(5),
325            ..s3_config("b", endpoint)
326        }))
327        .unwrap();
328
329        let result = store.get(&Path::from("throttled")).await;
330        server.abort();
331
332        assert!(result.is_err());
333        check!(attempts.load(Ordering::SeqCst) == 3);
334    }
335}