Skip to main content

lfsx_server/storage/s3/
keyspace.rs

1use std::time::Duration;
2
3use axum::body::Bytes;
4use futures_util::Stream;
5use rusty_s3::actions::{
6    DeleteObject, GetObject, HeadBucket, HeadObject, ListObjectsV2, PutObject, S3Action,
7};
8use rusty_s3::{Bucket, Credentials, UrlStyle};
9
10use crate::error::Error;
11use crate::storage::s3::S3Config;
12
13const COPY_SOURCE: &str = "x-amz-copy-source";
14
15// One key as the store describes it.
16// Everything a listing found, and whether it ran out before the end.
17pub(crate) struct Listing {
18    pub(crate) entries: Vec<Entry>,
19    pub(crate) complete: bool,
20}
21
22pub(crate) struct Entry {
23    pub(crate) key: String,
24    last_modified: String,
25    pub(crate) size: u64,
26}
27
28impl Entry {
29    // None when the store's timestamp cannot be read, which is treated as "too
30    // young to touch": deleting somebody's upload on the strength of a date this
31    // server could not parse is the wrong way to be wrong.
32    pub(crate) fn age(&self) -> Option<Duration> {
33        let written = time::OffsetDateTime::parse(
34            &self.last_modified,
35            &time::format_description::well_known::Rfc3339,
36        )
37        .ok()?;
38
39        Duration::try_from(time::OffsetDateTime::now_utc() - written).ok()
40    }
41}
42
43// An href a client uses directly, and the headers it has to send with it. The
44// headers are part of the signature, so they are not advice.
45pub struct Presigned {
46    pub href: String,
47    pub headers: Vec<(String, String)>,
48}
49
50// The same layout as the local store, for the same reasons. The bytes live once
51// under a key derived from their digest, and a repository that holds them owns
52// an empty marker beside it — the object store's answer to a hard link. It is
53// what keeps two projects sharing an asset pack from paying twice, and what
54// stops a repository reading an object it never pushed: the marker is the proof
55// of possession, and it is the only thing the permission check consults.
56// A conditional write that is refused makes the store answer and hang up, and
57// the connection goes back into the pool looking usable. The next request on it
58// fails at the transport layer with nothing to do with the store's health, which
59// is how a losing `git lfs lock` came back as a 500 instead of a 409.
60//
61// Retried once, and only for requests that carry no body: a GET and a HEAD can be
62// repeated with no consequence, so a dead connection costs a round trip rather
63// than an error. A PUT is not retried here.
64async fn read_retrying(request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
65    let retry = request.try_clone();
66
67    match request.send().await {
68        Ok(response) => Ok(response),
69        Err(_) => match retry {
70            Some(retry) => retry.send().await.map_err(|_| unreachable_store()),
71            None => Err(unreachable_store()),
72        },
73    }
74}
75
76fn unreachable_store() -> Error {
77    Error::Storage(std::io::Error::other("the object store is unreachable"))
78}
79
80// The bucket as a keyspace: whole values written, read, deleted and listed by
81// key, with the signing and the HTTP client in one place. It knows nothing about
82// objects, oids or repositories — what a key means is decided a layer up, which
83// is what lets the lock store share the bucket with the object store without
84// either of them reaching into the other.
85#[derive(Clone)]
86pub struct Keyspace {
87    bucket: Bucket,
88    credentials: Credentials,
89    client: reqwest::Client,
90    lifetime: Duration,
91}
92
93impl Keyspace {
94    pub fn new(config: &S3Config) -> Result<Self, Error> {
95        crate::tls::install_crypto_provider();
96
97        let style = if config.path_style {
98            UrlStyle::Path
99        } else {
100            UrlStyle::VirtualHost
101        };
102
103        let bucket = Bucket::new(
104            config
105                .endpoint
106                .parse()
107                .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
108            style,
109            config.bucket.clone(),
110            config.region.clone(),
111        )
112        .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
113
114        Ok(Self {
115            bucket,
116            credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
117            client: reqwest::Client::new(),
118            lifetime: config.lifetime,
119        })
120    }
121
122    // Whether this server can reach the store at all, which is one HEAD on the
123    // bucket. Only the status is reported: the store says why in a body that
124    // names the bucket, and readiness is answered to whoever asks.
125    pub(crate) async fn reachable(&self) -> Result<(), Error> {
126        let action = HeadBucket::new(&self.bucket, Some(&self.credentials));
127        let response = read_retrying(self.client.head(action.sign(self.lifetime))).await?;
128
129        if !response.status().is_success() {
130            return Err(Error::Storage(std::io::Error::other(format!(
131                "the object store answered {} for the bucket",
132                response.status()
133            ))));
134        }
135
136        Ok(())
137    }
138
139    // A signature handed to a client so it reads the key straight from the store.
140    // Whether the caller is entitled to the bytes is settled before this is
141    // called: the signature is scoped to one key and it expires.
142    pub(crate) fn signed_download(&self, key: &str) -> String {
143        GetObject::new(&self.bucket, Some(&self.credentials), key)
144            .sign(self.lifetime)
145            .to_string()
146    }
147
148    // The same for a write, with headers bound into the signature rather than
149    // merely suggested: the store refuses a body that does not match them, which
150    // is what makes handing out a write URL safe at all.
151    pub(crate) fn signed_upload(&self, key: &str, headers: Vec<(String, String)>) -> Presigned {
152        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
153
154        for (name, value) in &headers {
155            action
156                .headers_mut()
157                .insert(name.clone(), std::borrow::Cow::Owned(value.clone()));
158        }
159
160        Presigned {
161            href: action.sign(self.lifetime).to_string(),
162            headers,
163        }
164    }
165
166    // A ranged read streamed rather than buffered: a value here can be measured
167    // in gigabytes, and the whole storage layer is built on holding at most a few
168    // megabytes of one at a time.
169    pub(crate) async fn get_range(
170        &self,
171        key: &str,
172        start: u64,
173        length: u64,
174    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
175        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
176
177        let response = self
178            .client
179            .get(action.sign(self.lifetime))
180            .header(
181                reqwest::header::RANGE,
182                format!("bytes={start}-{}", start + length.saturating_sub(1)),
183            )
184            .send()
185            .await
186            .map_err(|_| unreachable_store())?;
187
188        if !response.status().is_success() {
189            return Err(Error::NotFound);
190        }
191
192        Ok(response.bytes_stream())
193    }
194
195    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
196    // method, and an implementation that checks it — which is the point of
197    // testing against MinIO and Garage rather than only AWS — is entitled to
198    // refuse the mismatch.
199    pub(crate) async fn head(&self, key: &str) -> Result<u64, Error> {
200        let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
201        let url = action.sign(self.lifetime);
202
203        let response = read_retrying(self.client.head(url)).await?;
204
205        if !response.status().is_success() {
206            return Err(Error::NotFound);
207        }
208
209        // Read the header rather than the body length: a HEAD has no body, and
210        // asking the response how long it is answers about what was received
211        // rather than what is there.
212        response
213            .headers()
214            .get(reqwest::header::CONTENT_LENGTH)
215            .and_then(|value| value.to_str().ok())
216            .and_then(|value| value.parse().ok())
217            .ok_or_else(|| {
218                Error::Storage(std::io::Error::other(
219                    "the object store gave no object size",
220                ))
221            })
222    }
223
224    pub(crate) async fn put(
225        &self,
226        key: &str,
227        body: reqwest::Body,
228        length: u64,
229    ) -> Result<(), Error> {
230        let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
231        let url = action.sign(self.lifetime);
232
233        let response = self
234            .client
235            .put(url)
236            // S3 has no use for a chunked body and answers 501 rather than
237            // starting the upload. reqwest cannot infer a length from a stream,
238            // so it comes from the staging file being sent.
239            .header(reqwest::header::CONTENT_LENGTH, length)
240            .body(body)
241            .send()
242            .await
243            .map_err(|_| {
244                Error::Storage(std::io::Error::other("the object store is unreachable"))
245            })?;
246
247        let status = response.status();
248        if !status.is_success() {
249            // The store says why in the body, and an operator staring at a
250            // failing push has nothing else to go on: a bucket that does not
251            // exist, a key that is denied and a clock that has drifted are three
252            // different afternoons.
253            let detail = response.text().await.unwrap_or_default();
254
255            return Err(Error::Storage(std::io::Error::other(format!(
256                "the object store refused a write with {status}: {}",
257                detail.trim()
258            ))));
259        }
260
261        Ok(())
262    }
263
264    // A copy is a PUT to the destination carrying `x-amz-copy-source`, so this is
265    // a signed PutObject with that header bound rather than a separate action.
266    // The bytes move inside the store: nothing crosses this server.
267    pub(crate) async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
268        let source = format!("/{}/{from}", self.bucket.name());
269        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), to);
270        action
271            .headers_mut()
272            .insert(COPY_SOURCE, std::borrow::Cow::Owned(source.clone()));
273
274        let response = self
275            .client
276            .put(action.sign(self.lifetime))
277            .header(COPY_SOURCE, source)
278            .header(reqwest::header::CONTENT_LENGTH, 0)
279            .send()
280            .await
281            .map_err(|_| unreachable_store())?;
282
283        self.expect_success(response, "copy").await?;
284
285        Ok(())
286    }
287
288    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
289    // `If-None-Match: *` is a conditional write: the store itself decides who
290    // arrived first, and answers 412 to everyone after. Without it two replicas
291    // sharing a bucket would each believe they took the lock.
292    //
293    // The header is bound into the signature and sent alongside, so a store that
294    // ignores conditional writes cannot silently accept both.
295    pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
296        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
297        action.headers_mut().insert("if-none-match", "*");
298        let url = action.sign(self.lifetime);
299
300        let length = body.len();
301        let response = self
302            .client
303            .put(url)
304            .header("if-none-match", "*")
305            .header(reqwest::header::CONTENT_LENGTH, length)
306            .body(body)
307            .send()
308            .await
309            .map_err(|_| unreachable_store())?;
310
311        if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
312            return Ok(false);
313        }
314
315        self.expect_success(response, "write").await?;
316
317        Ok(true)
318    }
319
320    pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
321        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
322        let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
323
324        if response.status() == reqwest::StatusCode::NOT_FOUND {
325            return Ok(None);
326        }
327
328        let response = self.expect_success(response, "read").await?;
329
330        response
331            .bytes()
332            .await
333            .map(|bytes| Some(bytes.to_vec()))
334            .map_err(|_| unreachable_store())
335    }
336
337    pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
338        // S3 answers 204 whether or not the key was there, so whether this
339        // removed anything is settled before asking.
340        let existed = self.head(key).await.is_ok();
341
342        let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
343        let response = self
344            .client
345            .delete(action.sign(self.lifetime))
346            .send()
347            .await
348            .map_err(|_| unreachable_store())?;
349
350        self.expect_success(response, "delete").await?;
351
352        Ok(existed)
353    }
354
355    // Every key under a prefix, following the continuation token to the end.
356    // Stopping at the first page would report a repository holding a thousand
357    // locks as holding a thousand and none of the rest, and a lock nobody can
358    // see is a lock nobody respects.
359    pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
360        Ok(self
361            .entries(prefix)
362            .await?
363            .into_iter()
364            .map(|entry| entry.key)
365            .collect())
366    }
367
368    // A listing that says whether it finished. Collection needs the difference:
369    // concluding "no marker anywhere references this object" from a listing that
370    // stopped halfway is how a sweep deletes bytes another repository still
371    // holds. Everything else wants the strict form and gets `entries`.
372    pub(crate) async fn listing(&self, prefix: &str) -> Listing {
373        match self.entries(prefix).await {
374            Ok(entries) => Listing {
375                entries,
376                complete: true,
377            },
378            Err(error) => {
379                tracing::warn!(%error, prefix, "the listing could not be finished");
380                Listing {
381                    entries: Vec::new(),
382                    complete: false,
383                }
384            }
385        }
386    }
387
388    pub(crate) async fn entries(&self, prefix: &str) -> Result<Vec<Entry>, Error> {
389        let mut out = Vec::new();
390        let mut token: Option<String> = None;
391
392        loop {
393            let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
394            action.with_prefix(prefix);
395            if let Some(token) = &token {
396                action.with_continuation_token(token);
397            }
398
399            let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
400            let body = self
401                .expect_success(response, "list")
402                .await?
403                .text()
404                .await
405                .map_err(|_| unreachable_store())?;
406
407            let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
408                Error::Storage(std::io::Error::other(format!(
409                    "the object store sent a listing this server could not read: {error}"
410                )))
411            })?;
412
413            out.extend(listing.contents.into_iter().map(|object| Entry {
414                key: object.key,
415                last_modified: object.last_modified,
416                size: object.size,
417            }));
418
419            match listing.next_continuation_token {
420                Some(next) => token = Some(next),
421                None => break,
422            }
423        }
424
425        Ok(out)
426    }
427
428    async fn expect_success(
429        &self,
430        response: reqwest::Response,
431        what: &str,
432    ) -> Result<reqwest::Response, Error> {
433        let status = response.status();
434        if status.is_success() {
435            return Ok(response);
436        }
437
438        let detail = response.text().await.unwrap_or_default();
439
440        Err(Error::Storage(std::io::Error::other(format!(
441            "the object store refused a {what} with {status}: {}",
442            detail.trim()
443        ))))
444    }
445}