Skip to main content

objectstore_service/backend/
s3_compatible.rs

1//! S3-compatible backend with generic protocol support.
2
3use std::time::SystemTime;
4use std::{fmt, io};
5
6use futures_util::{StreamExt, TryStreamExt};
7use objectstore_types::metadata::{HEADER_SIZE, Metadata};
8use objectstore_types::range::{ByteRange, ContentRange};
9use reqwest::header::{HeaderMap, HeaderName};
10use reqwest::{Body, IntoUrl, Method, RequestBuilder, Response, StatusCode};
11
12use super::extensions::{ResponseExt, SendTraced};
13use crate::backend::common::{
14    self, Backend, DeleteResponse, GetResponse, MetadataResponse, PutResponse,
15};
16use crate::error::{Error, Result};
17use crate::id::ObjectId;
18use crate::stream::ClientStream;
19
20/// Configuration for [`S3CompatibleBackend`].
21///
22/// Supports [Amazon S3] and other S3-compatible services. Authentication is handled via
23/// environment variables (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) or IAM roles.
24///
25/// [Amazon S3]: https://aws.amazon.com/s3/
26///
27/// # Example
28///
29/// ```yaml
30/// storage:
31///   type: s3compatible
32///   endpoint: https://s3.amazonaws.com
33///   bucket: my-bucket
34/// ```
35#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
36pub struct S3CompatibleConfig {
37    /// S3 endpoint URL.
38    ///
39    /// Examples: `https://s3.amazonaws.com`, `http://localhost:9000` (for MinIO)
40    ///
41    /// # Environment Variables
42    ///
43    /// - `OS__STORAGE__TYPE=s3compatible`
44    /// - `OS__STORAGE__ENDPOINT=https://s3.amazonaws.com`
45    pub endpoint: String,
46
47    /// S3 bucket name.
48    ///
49    /// The bucket must exist before starting the server.
50    ///
51    /// # Environment Variables
52    ///
53    /// - `OS__STORAGE__BUCKET=my-bucket`
54    pub bucket: String,
55}
56
57/// Prefix used for custom metadata in headers for the GCS backend.
58///
59/// See: <https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogmeta>
60const GCS_CUSTOM_PREFIX: &str = "x-goog-meta-";
61/// Header used to store the expiration time for GCS using the `daysSinceCustomTime` lifecycle
62/// condition.
63///
64/// See: <https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogcustomtime>
65const GCS_CUSTOM_TIME: &str = "x-goog-custom-time";
66
67/// An authentication token that can be passed as a bearer credential.
68pub trait Token: Send + Sync {
69    /// Returns the token string.
70    fn as_str(&self) -> &str;
71}
72
73/// Provides authentication tokens for S3-compatible requests.
74pub trait TokenProvider: Send + Sync + 'static {
75    /// Returns a fresh token, fetching or refreshing it as needed.
76    fn get_token(&self) -> impl Future<Output = anyhow::Result<impl Token>> + Send;
77}
78
79/// Placeholder [`TokenProvider`] for unauthenticated backends.
80#[derive(Debug)]
81pub struct NoToken;
82
83impl TokenProvider for NoToken {
84    #[allow(refining_impl_trait)]
85    async fn get_token(&self) -> anyhow::Result<NoToken> {
86        unimplemented!()
87    }
88}
89impl Token for NoToken {
90    fn as_str(&self) -> &str {
91        unimplemented!()
92    }
93}
94
95/// S3-compatible storage backend with pluggable authentication.
96pub struct S3CompatibleBackend<T> {
97    client: reqwest::Client,
98
99    endpoint: String,
100    bucket: String,
101
102    token_provider: Option<T>,
103}
104
105impl<T> S3CompatibleBackend<T> {
106    /// Creates a new S3-compatible backend bound to the given bucket.
107    pub fn new(endpoint: &str, bucket: &str, token_provider: T) -> Self {
108        Self {
109            client: common::reqwest_client(),
110            endpoint: endpoint.into(),
111            bucket: bucket.into(),
112            token_provider: Some(token_provider),
113        }
114    }
115
116    /// Formats the S3 object URL for the given key.
117    fn object_url(&self, id: &ObjectId) -> String {
118        format!("{}/{}/{}", self.endpoint, self.bucket, id.as_storage_path())
119    }
120}
121
122/// Wraps [`Metadata::to_headers`] with GCS-specific concerns (tombstone + custom-time).
123fn metadata_to_gcs_headers(
124    metadata: &Metadata,
125    prefix: &str,
126) -> Result<HeaderMap, objectstore_types::metadata::Error> {
127    let mut headers = metadata.to_headers(prefix)?;
128
129    // The size is derived from the native `Content-Length` on every read, so it must not be
130    // persisted: metadata updates rewrite *all* stored metadata, and a stored `x-sn-size` key
131    // is rejected by the GCS JSON backend when it deserializes the object.
132    let size = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
133    headers.remove(&size);
134
135    // GCS custom-time for lifecycle expiration
136    if let Some(expires_at) = metadata.time_expires {
137        let expires_at = humantime::format_rfc3339_seconds(expires_at);
138        headers.append(GCS_CUSTOM_TIME, expires_at.to_string().parse()?);
139    }
140    Ok(headers)
141}
142
143impl<T> S3CompatibleBackend<T>
144where
145    T: TokenProvider,
146{
147    /// Creates a request builder with the appropriate authentication.
148    async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
149        let mut builder = self.client.request(method, url);
150        if let Some(provider) = &self.token_provider {
151            builder = builder.bearer_auth(
152                provider
153                    .get_token()
154                    .await
155                    .map_err(|err| Error::Generic {
156                        context: "S3: failed to get authentication token".to_owned(),
157                        cause: Some(err.into()),
158                    })?
159                    .as_str(),
160            );
161        }
162        Ok(builder)
163    }
164
165    /// Fetches object metadata using the given HTTP method (GET or HEAD),
166    /// bumps TTI if needed, and returns the parsed metadata along with the
167    /// response (so `get_object` can read the body from a GET).
168    async fn request_object(
169        &self,
170        method: Method,
171        id: &ObjectId,
172        range: Option<ByteRange>,
173    ) -> Result<Option<(Metadata, Option<ContentRange>, Response)>> {
174        let object_url = self.object_url(id);
175
176        let mut builder = self.request(method, &object_url).await?;
177        if let Some(r) = range {
178            builder = builder.header(reqwest::header::RANGE, r.to_header_value());
179        }
180        let response = builder
181            .send_traced()
182            .await
183            .map_err(|cause| Error::Reqwest {
184                context: "S3: failed to send request".to_string(),
185                cause,
186            })?;
187
188        if response.status() == StatusCode::NOT_FOUND {
189            objectstore_log::debug!("Object not found");
190            response.drain_body().await;
191            return Ok(None);
192        }
193
194        if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
195            let raw = response
196                .headers()
197                .get(reqwest::header::CONTENT_RANGE)
198                .and_then(|v| v.to_str().ok());
199            let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
200            let err = match total {
201                Some(total) => Error::RangeNotSatisfiable { total },
202                None => Error::generic(format!(
203                    "S3: 416 response with invalid Content-Range: {raw:?}"
204                )),
205            };
206            response.drain_body().await;
207            return Err(err);
208        }
209
210        let response = response.check_error("S3: failed to get object").await?;
211
212        let headers = response.headers();
213        let mut metadata = Metadata::from_headers(headers, GCS_CUSTOM_PREFIX)?;
214
215        let content_range = if response.status() == StatusCode::PARTIAL_CONTENT {
216            let range = headers
217                .get(reqwest::header::CONTENT_RANGE)
218                .and_then(|v| v.to_str().ok())
219                .and_then(|s| s.parse::<ContentRange>().ok())
220                .ok_or_else(|| Error::Generic {
221                    context: "S3: 206 response missing valid Content-Range header".to_owned(),
222                    cause: None,
223                })?;
224            metadata.size = Some(range.total as usize);
225            Some(range)
226        } else {
227            // NB: Read the header rather than `Response::content_length`, which reports the
228            // length of the decoded body and is therefore always zero for a HEAD response.
229            let size = headers
230                .get(reqwest::header::CONTENT_LENGTH)
231                .and_then(|value| value.to_str().ok())
232                .map(|value| value.parse::<usize>())
233                .transpose()
234                .map_err(|cause| Error::Generic {
235                    context: "S3: failed to parse Content-Length from object response".to_string(),
236                    cause: Some(Box::new(cause)),
237                })?;
238
239            if let Some(size) = size {
240                metadata.size = Some(size);
241            } else {
242                objectstore_log::warn!("S3: 200 response missing Content-Length header");
243            }
244            None
245        };
246
247        // TODO: Inject the access time from the request.
248        let access_time = SystemTime::now();
249
250        // Filter already expired objects but leave them to garbage collection
251        if metadata.expiration_policy.is_timeout()
252            && metadata.time_expires.is_some_and(|ts| ts < access_time)
253        {
254            objectstore_log::debug!("Object found but past expiry");
255            response.drain_body().await;
256            return Ok(None);
257        }
258
259        // TODO: extract into dedicated call from service
260        // TODO: Schedule into background persistently so this doesn't get lost on restarts
261        if let Some(new_expire_at) = metadata.check_tti_bump(access_time) {
262            let mut bumped = metadata.clone();
263            bumped.time_expires = Some(new_expire_at);
264            self.update_metadata(id, &bumped).await?;
265        }
266
267        Ok(Some((metadata, content_range, response)))
268    }
269
270    /// Issues a request to update the metadata for the given object.
271    async fn update_metadata(&self, id: &ObjectId, metadata: &Metadata) -> Result<()> {
272        // NB: Meta updates require copy + REPLACE along with *all* metadata. See
273        // https://cloud.google.com/storage/docs/xml-api/put-object-copy
274        self.request(Method::PUT, self.object_url(id))
275            .await?
276            .header(
277                "x-goog-copy-source",
278                format!("/{}/{}", self.bucket, id.as_storage_path()),
279            )
280            .header("x-goog-metadata-directive", "REPLACE")
281            .headers(metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)?)
282            .send_traced()
283            .await
284            .check_error("S3: update expiration time")
285            .await?
286            .drain_body()
287            .await;
288
289        Ok(())
290    }
291}
292
293impl<T> fmt::Debug for S3CompatibleBackend<T> {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        f.debug_struct("S3Compatible")
296            .field("client", &self.client)
297            .field("endpoint", &self.endpoint)
298            .field("bucket", &self.bucket)
299            .finish_non_exhaustive()
300    }
301}
302
303impl S3CompatibleBackend<NoToken> {
304    /// Creates a new S3-compatible backend that sends unauthenticated requests.
305    pub fn without_token(config: S3CompatibleConfig) -> Self {
306        Self {
307            client: common::reqwest_client(),
308            endpoint: config.endpoint,
309            bucket: config.bucket,
310            token_provider: None,
311        }
312    }
313}
314
315#[async_trait::async_trait]
316impl<T: TokenProvider> Backend for S3CompatibleBackend<T> {
317    fn name(&self) -> &'static str {
318        "s3-compatible"
319    }
320
321    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
322    async fn put_object(
323        &self,
324        id: &ObjectId,
325        metadata: &Metadata,
326        stream: ClientStream,
327    ) -> Result<PutResponse> {
328        objectstore_log::debug!("Writing to s3_compatible backend");
329        self.request(Method::PUT, self.object_url(id))
330            .await?
331            .headers(metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)?)
332            .body(Body::wrap_stream(stream))
333            .send_traced()
334            .await
335            .check_error("S3: failed to put object")
336            .await?
337            .drain_body()
338            .await;
339
340        Ok(())
341    }
342
343    #[tracing::instrument(level = "debug", skip(self))]
344    async fn get_object(&self, id: &ObjectId, range: Option<ByteRange>) -> Result<GetResponse> {
345        objectstore_log::debug!("Reading from s3_compatible backend");
346
347        let Some((metadata, content_range, response)) =
348            self.request_object(Method::GET, id, range).await?
349        else {
350            return Ok(None);
351        };
352
353        let stream = response.bytes_stream().map_err(io::Error::other);
354        Ok(Some((metadata, content_range, stream.boxed())))
355    }
356
357    #[tracing::instrument(level = "debug", skip(self))]
358    async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
359        objectstore_log::debug!("Reading metadata from s3_compatible backend");
360        let response = self.request_object(Method::HEAD, id, None).await?;
361        Ok(response.map(|(metadata, _, _)| metadata))
362    }
363
364    #[tracing::instrument(level = "debug", skip(self))]
365    async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse> {
366        objectstore_log::debug!("Deleting from s3_compatible backend");
367        let response = self
368            .request(Method::DELETE, self.object_url(id))
369            .await?
370            .send_traced()
371            .await
372            .map_err(|cause| Error::Reqwest {
373                context: "S3: failed to send delete request".to_string(),
374                cause,
375            })?;
376
377        // Do not error for objects that do not exist.
378        if response.status() == StatusCode::NOT_FOUND {
379            response.drain_body().await;
380            return Ok(());
381        }
382
383        response
384            .check_error("S3: failed to delete object")
385            .await?
386            .drain_body()
387            .await;
388
389        Ok(())
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use std::collections::BTreeMap;
396    use std::time::Duration;
397
398    use anyhow::Result;
399    use objectstore_types::metadata::ExpirationPolicy;
400    use objectstore_types::scope::{Scope, Scopes};
401
402    use super::*;
403    use crate::backend::common::Backend;
404    use crate::id::ObjectContext;
405    use crate::stream;
406
407    // NB: To run these tests, you need to have a MinIO server running. This is done
408    // automatically in CI.
409    //
410    // Refer to the readme for how to set up MinIO via devservices.
411
412    fn create_test_backend() -> S3CompatibleBackend<NoToken> {
413        S3CompatibleBackend::without_token(S3CompatibleConfig {
414            endpoint: "http://localhost:8089".into(),
415            bucket: "test-bucket".into(),
416        })
417    }
418
419    fn make_id() -> ObjectId {
420        ObjectId::random(ObjectContext {
421            usecase: "testing".into(),
422            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
423        })
424    }
425
426    #[test]
427    fn metadata_to_gcs_headers_omits_size() {
428        let metadata = Metadata {
429            size: Some(4096),
430            ..Default::default()
431        };
432
433        let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
434
435        // Persisting the size would store a key that the GCS JSON backend rejects on read.
436        assert!(headers.get("x-goog-meta-x-sn-size").is_none());
437    }
438
439    #[test]
440    fn metadata_to_gcs_headers_uses_time_expires() {
441        let expires = SystemTime::now() + Duration::from_hours(1);
442        let metadata = Metadata {
443            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
444            time_expires: Some(expires),
445            ..Default::default()
446        };
447
448        let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
449
450        // The lifecycle custom-time is the server-resolved expiry (second precision).
451        let custom_time = headers.get(GCS_CUSTOM_TIME).unwrap().to_str().unwrap();
452        let expected = humantime::format_rfc3339_seconds(expires).to_string();
453        assert_eq!(custom_time, expected);
454    }
455
456    #[test]
457    fn metadata_to_gcs_headers_escapes_unicode() {
458        let metadata = Metadata {
459            filename: Some("réport-📄.pdf".into()),
460            custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
461            ..Default::default()
462        };
463
464        let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
465        assert_eq!(
466            headers.get("x-goog-meta-x-sn-filename").unwrap(),
467            "r%C3%A9port-%F0%9F%93%84.pdf",
468        );
469        assert_eq!(
470            headers.get("x-goog-meta-x-snme-release").unwrap(),
471            "v%C3%A9rsion-1.0-%F0%9F%9A%80",
472        );
473
474        // The prefixed headers this backend writes are the ones it reads back.
475        let roundtripped = Metadata::from_headers(&headers, GCS_CUSTOM_PREFIX).unwrap();
476        assert_eq!(roundtripped.filename, metadata.filename);
477        assert_eq!(roundtripped.custom, metadata.custom);
478    }
479
480    #[tokio::test]
481    async fn test_get_metadata_nonexistent() -> Result<()> {
482        let backend = create_test_backend();
483        let id = make_id();
484        let result = backend.get_metadata(&id).await?;
485        assert!(result.is_none());
486        Ok(())
487    }
488
489    #[tokio::test]
490    #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
491    async fn test_get_metadata_reports_size() -> Result<()> {
492        let backend = create_test_backend();
493        let id = make_id();
494        let payload = "hello, world";
495
496        backend
497            .put_object(&id, &Metadata::default(), stream::single(payload))
498            .await?;
499
500        // The size must come from the `Content-Length` header, not from the (empty) body of
501        // the HEAD response.
502        let metadata = backend.get_metadata(&id).await?.expect("object exists");
503        assert_eq!(metadata.size, Some(payload.len()));
504
505        Ok(())
506    }
507
508    #[tokio::test]
509    #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
510    async fn test_ttl_immediate() -> Result<()> {
511        let backend = create_test_backend();
512        let id = make_id();
513        let metadata = Metadata {
514            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
515            time_expires: Some(SystemTime::now()),
516            ..Default::default()
517        };
518
519        backend
520            .put_object(&id, &metadata, stream::single("hello, world"))
521            .await?;
522
523        let get_result = backend.get_object(&id, None).await?;
524        assert!(get_result.is_none());
525
526        let head_result = backend.get_metadata(&id).await?;
527        assert!(head_result.is_none());
528
529        Ok(())
530    }
531
532    #[tokio::test]
533    #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
534    async fn test_tti_immediate() -> Result<()> {
535        let backend = create_test_backend();
536        let id = make_id();
537        let metadata = Metadata {
538            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
539            time_expires: Some(SystemTime::now()),
540            ..Default::default()
541        };
542
543        backend
544            .put_object(&id, &metadata, stream::single("hello, world"))
545            .await?;
546
547        let get_result = backend.get_object(&id, None).await?;
548        assert!(get_result.is_none());
549
550        let head_result = backend.get_metadata(&id).await?;
551        assert!(head_result.is_none());
552
553        Ok(())
554    }
555}