1#[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#[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#[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
54pub 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 #[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 #[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 #[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 #[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 #[tokio::test]
247 async fn s3_request_timeout_bounds_a_stalled_store() {
248 let HttpListener { listener, endpoint } = http_listener().await;
249 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 #[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}