Skip to main content

lfsx_server/storage/
s3.rs

1use std::time::Duration;
2
3use axum::body::Bytes;
4use futures_util::Stream;
5use rusty_s3::actions::{DeleteObject, GetObject, HeadObject, ListObjectsV2, PutObject, S3Action};
6use rusty_s3::{Bucket, Credentials, UrlStyle};
7
8use crate::error::Error;
9use crate::namespace::Namespace;
10
11// The same layout as the local store, for the same reasons. The bytes live once
12// under a key derived from their digest, and a repository that holds them owns
13// an empty marker beside it — the object store's answer to a hard link. It is
14// what keeps two projects sharing an asset pack from paying twice, and what
15// stops a repository reading an object it never pushed: the marker is the proof
16// of possession, and it is the only thing the permission check consults.
17// A conditional write that is refused makes the store answer and hang up, and
18// the connection goes back into the pool looking usable. The next request on it
19// fails at the transport layer with nothing to do with the store's health, which
20// is how a losing `git lfs lock` came back as a 500 instead of a 409.
21//
22// Retried once, and only for requests that carry no body: a GET and a HEAD can be
23// repeated with no consequence, so a dead connection costs a round trip rather
24// than an error. A PUT is not retried here.
25async fn read_retrying(request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
26    let retry = request.try_clone();
27
28    match request.send().await {
29        Ok(response) => Ok(response),
30        Err(_) => match retry {
31            Some(retry) => retry.send().await.map_err(|_| unreachable_store()),
32            None => Err(unreachable_store()),
33        },
34    }
35}
36
37fn unreachable_store() -> Error {
38    Error::Storage(std::io::Error::other("the object store is unreachable"))
39}
40
41#[derive(Clone)]
42pub struct S3Store {
43    bucket: Bucket,
44    credentials: Credentials,
45    client: reqwest::Client,
46    lifetime: Duration,
47    redirect: bool,
48}
49
50pub struct S3Config {
51    pub endpoint: String,
52    pub bucket: String,
53    pub region: String,
54    pub access_key: String,
55    pub secret_key: String,
56    pub path_style: bool,
57    pub redirect: bool,
58    // How long a signature is good for. It is the same number the batch
59    // response advertises as `expires_in`, because a client told it has half an
60    // hour and handed a URL that dies in five minutes will fail a resume it had
61    // every reason to expect to work.
62    pub lifetime: Duration,
63}
64
65impl S3Store {
66    pub fn new(config: &S3Config) -> Result<Self, Error> {
67        crate::tls::install_crypto_provider();
68
69        let style = if config.path_style {
70            UrlStyle::Path
71        } else {
72            UrlStyle::VirtualHost
73        };
74
75        let bucket = Bucket::new(
76            config
77                .endpoint
78                .parse()
79                .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
80            style,
81            config.bucket.clone(),
82            config.region.clone(),
83        )
84        .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
85
86        Ok(Self {
87            bucket,
88            credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
89            client: reqwest::Client::new(),
90            lifetime: config.lifetime,
91            redirect: config.redirect,
92        })
93    }
94
95    fn content_key(oid: &str) -> String {
96        format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
97    }
98
99    fn marker_key(ns: &Namespace, oid: &str) -> String {
100        format!(
101            "{}/{}/{}/{}/{oid}",
102            ns.org(),
103            ns.repo(),
104            &oid[0..2],
105            &oid[2..4]
106        )
107    }
108
109    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
110        if crate::storage::LocalStore::validate_oid(oid).is_err() {
111            return false;
112        }
113
114        self.head(&Self::marker_key(ns, oid)).await.is_ok()
115    }
116
117    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
118    // method, and an implementation that checks it — which is the point of
119    // testing against MinIO and Garage rather than only AWS — is entitled to
120    // refuse the mismatch.
121    async fn head(&self, key: &str) -> Result<u64, Error> {
122        let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
123        let url = action.sign(self.lifetime);
124
125        let response = read_retrying(self.client.head(url)).await?;
126
127        if !response.status().is_success() {
128            return Err(Error::NotFound);
129        }
130
131        // Read the header rather than the body length: a HEAD has no body, and
132        // asking the response how long it is answers about what was received
133        // rather than what is there.
134        response
135            .headers()
136            .get(reqwest::header::CONTENT_LENGTH)
137            .and_then(|value| value.to_str().ok())
138            .and_then(|value| value.parse().ok())
139            .ok_or_else(|| {
140                Error::Storage(std::io::Error::other(
141                    "the object store gave no object size",
142                ))
143            })
144    }
145
146    pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
147        // Every entry point validates before slicing an oid into a key: the
148        // fanout takes the first four characters, so a short one is a panic
149        // rather than a refusal, and a panic is a 500 for something that should
150        // have been a 422.
151        crate::storage::LocalStore::validate_oid(oid)?;
152
153        self.head(&Self::content_key(oid)).await
154    }
155
156    // A download is streamed through this server rather than redirected, so the
157    // features that live in the byte path — the counters, the ranges, and the
158    // compression that will follow — keep working. The pre-signed redirect is a
159    // separate mode for operators who would rather spend the object store's
160    // bandwidth than their own.
161    pub async fn read(
162        &self,
163        oid: &str,
164        start: u64,
165        length: u64,
166    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
167        crate::storage::LocalStore::validate_oid(oid)?;
168
169        let key = Self::content_key(oid);
170        let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
171        let url = action.sign(self.lifetime);
172
173        let response = self
174            .client
175            .get(url)
176            .header(
177                reqwest::header::RANGE,
178                format!("bytes={start}-{}", start + length.saturating_sub(1)),
179            )
180            .send()
181            .await
182            .map_err(|_| {
183                Error::Storage(std::io::Error::other("the object store is unreachable"))
184            })?;
185
186        if !response.status().is_success() {
187            return Err(Error::NotFound);
188        }
189
190        Ok(response.bytes_stream())
191    }
192
193    // A URL the client fetches from the bucket directly, so the bytes never
194    // cross this server. Whether the caller is entitled to them has already been
195    // settled by the marker before this is called: the signature is scoped to
196    // one content key and expires, and it grants nothing the batch response was
197    // not about to grant anyway.
198    pub fn presigned_download(&self, oid: &str) -> Option<String> {
199        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
200            return None;
201        }
202
203        let key = Self::content_key(oid);
204
205        Some(
206            GetObject::new(&self.bucket, Some(&self.credentials), &key)
207                .sign(self.lifetime)
208                .to_string(),
209        )
210    }
211
212    async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
213        let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
214        let url = action.sign(self.lifetime);
215
216        let response = self
217            .client
218            .put(url)
219            // S3 has no use for a chunked body and answers 501 rather than
220            // starting the upload. reqwest cannot infer a length from a stream,
221            // so it comes from the staging file being sent.
222            .header(reqwest::header::CONTENT_LENGTH, length)
223            .body(body)
224            .send()
225            .await
226            .map_err(|_| {
227                Error::Storage(std::io::Error::other("the object store is unreachable"))
228            })?;
229
230        let status = response.status();
231        if !status.is_success() {
232            // The store says why in the body, and an operator staring at a
233            // failing push has nothing else to go on: a bucket that does not
234            // exist, a key that is denied and a clock that has drifted are three
235            // different afternoons.
236            let detail = response.text().await.unwrap_or_default();
237
238            return Err(Error::Storage(std::io::Error::other(format!(
239                "the object store refused a write with {status}: {}",
240                detail.trim()
241            ))));
242        }
243
244        Ok(())
245    }
246
247    // The upload has already been streamed to a staging file, hashed and checked
248    // against everything the server enforces, so that file is what goes up —
249    // streamed from disk rather than read into memory, because an object here is
250    // measured in gigabytes and the whole storage layer is built on holding at
251    // most a few megabytes of one at a time.
252    //
253    // The bytes go up once, keyed by their digest, and the marker records that
254    // this repository holds them. Content that is already there is skipped: the
255    // key would receive the same bytes it already has.
256    pub async fn store(
257        &self,
258        ns: &Namespace,
259        oid: &str,
260        staged: &std::path::Path,
261    ) -> Result<(), Error> {
262        crate::storage::LocalStore::validate_oid(oid)?;
263
264        if self.head(&Self::content_key(oid)).await.is_err() {
265            let file = tokio::fs::File::open(staged).await?;
266            let length = file.metadata().await?.len();
267            let stream = tokio_util::io::ReaderStream::new(file);
268
269            self.put(
270                &Self::content_key(oid),
271                reqwest::Body::wrap_stream(stream),
272                length,
273            )
274            .await?;
275        }
276
277        self.put(
278            &Self::marker_key(ns, oid),
279            reqwest::Body::from(Vec::new()),
280            0,
281        )
282        .await
283    }
284
285    // Everything below is the bucket as a keyspace rather than as an object
286    // store: whole small values, written, read, deleted and listed by key. The
287    // lock store is built on it, and it is kept here so the signing and the
288    // client stay in one place.
289
290    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
291    // `If-None-Match: *` is a conditional write: the store itself decides who
292    // arrived first, and answers 412 to everyone after. Without it two replicas
293    // sharing a bucket would each believe they took the lock.
294    //
295    // The header is bound into the signature and sent alongside, so a store that
296    // ignores conditional writes cannot silently accept both.
297    pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
298        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
299        action.headers_mut().insert("if-none-match", "*");
300        let url = action.sign(self.lifetime);
301
302        let length = body.len();
303        let response = self
304            .client
305            .put(url)
306            .header("if-none-match", "*")
307            .header(reqwest::header::CONTENT_LENGTH, length)
308            .body(body)
309            .send()
310            .await
311            .map_err(|_| unreachable_store())?;
312
313        if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
314            return Ok(false);
315        }
316
317        self.expect_success(response, "write").await?;
318
319        Ok(true)
320    }
321
322    pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
323        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
324        let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
325
326        if response.status() == reqwest::StatusCode::NOT_FOUND {
327            return Ok(None);
328        }
329
330        let response = self.expect_success(response, "read").await?;
331
332        response
333            .bytes()
334            .await
335            .map(|bytes| Some(bytes.to_vec()))
336            .map_err(|_| unreachable_store())
337    }
338
339    pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
340        // S3 answers 204 whether or not the key was there, so whether this
341        // removed anything is settled before asking.
342        let existed = self.head(key).await.is_ok();
343
344        let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
345        let response = self
346            .client
347            .delete(action.sign(self.lifetime))
348            .send()
349            .await
350            .map_err(|_| unreachable_store())?;
351
352        self.expect_success(response, "delete").await?;
353
354        Ok(existed)
355    }
356
357    // Every key under a prefix, following the continuation token to the end.
358    // Stopping at the first page would report a repository holding a thousand
359    // locks as holding a thousand and none of the rest, and a lock nobody can
360    // see is a lock nobody respects.
361    pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
362        let mut out = Vec::new();
363        let mut token: Option<String> = None;
364
365        loop {
366            let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
367            action.with_prefix(prefix);
368            if let Some(token) = &token {
369                action.with_continuation_token(token);
370            }
371
372            let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
373            let body = self
374                .expect_success(response, "list")
375                .await?
376                .text()
377                .await
378                .map_err(|_| unreachable_store())?;
379
380            let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
381                Error::Storage(std::io::Error::other(format!(
382                    "the object store sent a listing this server could not read: {error}"
383                )))
384            })?;
385
386            out.extend(listing.contents.into_iter().map(|object| object.key));
387
388            match listing.next_continuation_token {
389                Some(next) => token = Some(next),
390                None => break,
391            }
392        }
393
394        Ok(out)
395    }
396
397    async fn expect_success(
398        &self,
399        response: reqwest::Response,
400        what: &str,
401    ) -> Result<reqwest::Response, Error> {
402        let status = response.status();
403        if status.is_success() {
404            return Ok(response);
405        }
406
407        let detail = response.text().await.unwrap_or_default();
408
409        Err(Error::Storage(std::io::Error::other(format!(
410            "the object store refused a {what} with {status}: {}",
411            detail.trim()
412        ))))
413    }
414
415    // What the bucket holds for this repository, counted from its markers and
416    // the content they point at. The markers are empty, so their own size says
417    // nothing — this is a listing plus one head per object, which is why the
418    // figure is cached the same way the local one is.
419    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
420        let prefix = format!("{}/{}/", ns.org(), ns.repo());
421        let mut objects = 0;
422        let mut bytes = 0;
423
424        for oid in self.list(&prefix).await {
425            objects += 1;
426            bytes += self.size_of(&oid).await.unwrap_or_default();
427        }
428
429        (objects, bytes)
430    }
431
432    async fn list(&self, prefix: &str) -> Vec<String> {
433        let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
434        action.with_prefix(prefix);
435
436        // Every step logs what stopped it rather than returning an empty
437        // listing: a capacity figure that silently reads zero is worse than one
438        // that is missing, because it looks like an answer.
439        let response = match self.client.get(action.sign(self.lifetime)).send().await {
440            Ok(response) => response,
441            Err(error) => {
442                tracing::warn!(%error, "the object store could not be listed");
443                return Vec::new();
444            }
445        };
446
447        let body = match response.text().await {
448            Ok(body) => body,
449            Err(error) => {
450                tracing::warn!(%error, "the listing could not be read");
451                return Vec::new();
452            }
453        };
454
455        let listing = match ListObjectsV2::parse_response(&body) {
456            Ok(listing) => listing,
457            Err(error) => {
458                tracing::warn!(%error, "the listing could not be parsed");
459                return Vec::new();
460            }
461        };
462
463        listing
464            .contents
465            .into_iter()
466            .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
467            .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
468            .collect()
469    }
470}
471
472#[cfg(test)]
473pub(crate) mod tests;