lfsx_server/storage/s3.rs
1pub(crate) mod keyspace;
2pub(crate) mod multipart;
3pub(crate) mod probe;
4pub(crate) mod refs;
5
6use std::time::Duration;
7
8use axum::body::Bytes;
9use futures_util::Stream;
10
11use base64::Engine;
12
13use crate::error::Error;
14use crate::namespace::Namespace;
15use crate::storage::Reclaimed;
16
17pub use keyspace::{Keyspace, Presigned};
18
19const CHECKSUM: &str = "x-amz-checksum-sha256";
20
21pub struct S3Config {
22 pub endpoint: String,
23 pub bucket: String,
24 pub region: String,
25 pub access_key: String,
26 pub secret_key: String,
27 pub path_style: bool,
28 // How long a signature is good for. It is the same number the batch
29 // response advertises as `expires_in`, because a client told it has half an
30 // hour and handed a URL that dies in five minutes will fail a resume it had
31 // every reason to expect to work.
32 pub lifetime: Duration,
33}
34
35// The same layout as the local store, for the same reasons. The bytes live once
36// under a key derived from their digest, and a repository that holds them owns
37// an empty marker beside it — the object store's answer to a hard link. It is
38// what keeps two projects sharing an asset pack from paying twice, and what
39// stops a repository reading an object it never pushed: the marker is the proof
40// of possession, and it is the only thing the permission check consults.
41//
42// Everything below is object semantics. What it takes to talk to the store at
43// all — signing, retrying, listing, the client — is the keyspace underneath.
44#[derive(Clone)]
45pub struct S3Store {
46 keys: Keyspace,
47 redirect: bool,
48}
49
50impl S3Store {
51 pub fn new(keys: Keyspace, redirect: bool) -> Self {
52 Self { keys, redirect }
53 }
54
55 fn content_key(oid: &str) -> String {
56 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
57 }
58
59 // Where a client uploads to when the bytes never pass through this server.
60 // Per repository on purpose: the shared content key would take bytes from
61 // anyone allowed to write, and then nothing distinguishes a repository that
62 // uploaded an object from one that merely knew its digest. A key only this
63 // repository was handed a signature for is the proof of possession that the
64 // marker stands for everywhere else.
65 fn incoming_key(ns: &Namespace, oid: &str) -> String {
66 format!(
67 ".incoming/{}/{}/{}/{}/{oid}",
68 ns.org(),
69 ns.repo(),
70 &oid[0..2],
71 &oid[2..4]
72 )
73 }
74
75 fn marker_key(ns: &Namespace, oid: &str) -> String {
76 format!(
77 "{}/{}/{}/{}/{oid}",
78 ns.org(),
79 ns.repo(),
80 &oid[0..2],
81 &oid[2..4]
82 )
83 }
84
85 fn own_prefix(ns: &Namespace) -> String {
86 format!("{}/{}/", ns.org(), ns.repo())
87 }
88
89 pub async fn reachable(&self) -> Result<(), Error> {
90 self.keys.reachable().await
91 }
92
93 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
94 if crate::storage::LocalStore::validate_oid(oid).is_err() {
95 return false;
96 }
97
98 self.keys.head(&Self::marker_key(ns, oid)).await.is_ok()
99 }
100
101 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
102 // Every entry point validates before slicing an oid into a key: the
103 // fanout takes the first four characters, so a short one is a panic
104 // rather than a refusal, and a panic is a 500 for something that should
105 // have been a 422.
106 crate::storage::LocalStore::validate_oid(oid)?;
107
108 self.keys.head(&Self::content_key(oid)).await
109 }
110
111 // A download is streamed through this server rather than redirected, so the
112 // features that live in the byte path — the counters, the ranges, and the
113 // compression that will follow — keep working. The pre-signed redirect is a
114 // separate mode for operators who would rather spend the object store's
115 // bandwidth than their own.
116 pub async fn read(
117 &self,
118 oid: &str,
119 start: u64,
120 length: u64,
121 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
122 crate::storage::LocalStore::validate_oid(oid)?;
123
124 self.keys
125 .get_range(&Self::content_key(oid), start, length)
126 .await
127 }
128
129 // A URL the client fetches from the bucket directly, so the bytes never
130 // cross this server. Whether the caller is entitled to them has already been
131 // settled by the marker before this is called: the signature is scoped to
132 // one content key and expires, and it grants nothing the batch response was
133 // not about to grant anyway.
134 pub fn presigned_download(&self, oid: &str) -> Option<String> {
135 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
136 return None;
137 }
138
139 Some(self.keys.signed_download(&Self::content_key(oid)))
140 }
141
142 // A URL the client PUTs the object to, and the headers it has to send with
143 // it. The digest is bound into the signature, so the store refuses anything
144 // that does not hash to the object it was signed for: a client with this URL
145 // cannot put arbitrary bytes anywhere, which is what makes handing one out
146 // safe at all.
147 // None above the single-request ceiling, and that is not a refusal: the
148 // object falls back to coming through this server, which sends it in parts.
149 // A client cannot do the same, because the `basic` transfer adapter every
150 // git-lfs speaks does one PUT to one href and has nowhere to put a second.
151 // So the ceiling multipart removes for the streamed path is real and
152 // permanent for this one, and the only question is whether the client learns
153 // it now or after uploading five gigabytes.
154 //
155 // It also keeps `adopt` honest: `CopyObject` stops at the same 5 GiB, and
156 // nothing can reach `.incoming/` above it while this holds.
157 pub fn presigned_upload(&self, ns: &Namespace, oid: &str, size: u64) -> Option<Presigned> {
158 if !self.redirect
159 || size > multipart::SINGLE_PUT_CEILING
160 || crate::storage::LocalStore::validate_oid(oid).is_err()
161 {
162 return None;
163 }
164
165 let digest = base64::engine::general_purpose::STANDARD.encode(hex::decode(oid).ok()?);
166
167 Some(self.keys.signed_upload(
168 &Self::incoming_key(ns, oid),
169 vec![(CHECKSUM.to_owned(), digest)],
170 ))
171 }
172
173 // How big the object a client uploaded actually is, which is the first thing
174 // this server learns about it: nothing measured the bytes on the way past.
175 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<u64, Error> {
176 crate::storage::LocalStore::validate_oid(oid)?;
177
178 self.keys.head(&Self::incoming_key(ns, oid)).await
179 }
180
181 // Take an upload that landed under this repository's own key into the shared
182 // keyspace. The bytes are already known to hash to the oid, because the store
183 // refused everything else.
184 pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
185 crate::storage::LocalStore::validate_oid(oid)?;
186
187 let incoming = Self::incoming_key(ns, oid);
188 let content = Self::content_key(oid);
189
190 // First, before anything here so much as looks at the content.
191 //
192 // The marker is the claim and the ref is the index of it, so a crash
193 // between the two has to leave a ref nobody claims rather than a claim
194 // nothing indexes: the first leaks an object, the second lets a later
195 // sweep free bytes this repository holds.
196 //
197 // Writing it up here rather than beside the marker costs nothing and buys
198 // the race below. A sweep asks the index one last time before deleting
199 // bytes, so a claim recorded before this repository even checked whether
200 // the content exists is a claim that sweep will see.
201 refs::write(&self.keys, ns, oid).await?;
202
203 // Already there means another repository pushed the same object, and the
204 // bytes are identical by construction.
205 if self.keys.head(&content).await.is_err() {
206 self.keys.copy(&incoming, &content).await?;
207 }
208
209 self.keys
210 .put(
211 &Self::marker_key(ns, oid),
212 reqwest::Body::from(Vec::new()),
213 0,
214 )
215 .await?;
216
217 // Leaving it would pay for the object twice. A failure here is not worth
218 // failing the push over: the object is adopted, and what is left is a key
219 // the operator can see.
220 if let Err(error) = self.keys.delete(&incoming).await {
221 tracing::warn!(%error, key = incoming, "an adopted upload could not be cleaned up");
222 }
223
224 Ok(())
225 }
226
227 // The upload has already been streamed to a staging file, hashed and checked
228 // against everything the server enforces, so that file is what goes up —
229 // streamed from disk rather than read into memory, because an object here is
230 // measured in gigabytes and the whole storage layer is built on holding at
231 // most a few megabytes of one at a time.
232 //
233 // The bytes go up once, keyed by their digest, and the marker records that
234 // this repository holds them. Content that is already there is skipped: the
235 // key would receive the same bytes it already has.
236 pub async fn store(
237 &self,
238 ns: &Namespace,
239 oid: &str,
240 staged: &std::path::Path,
241 ) -> Result<(), Error> {
242 crate::storage::LocalStore::validate_oid(oid)?;
243
244 // Before the content is even looked at, for the reason `adopt` gives:
245 // this is what a sweep re-reads before deleting bytes, so a claim
246 // recorded here cannot be missed by one that is already deciding.
247 refs::write(&self.keys, ns, oid).await?;
248
249 if self.keys.head(&Self::content_key(oid)).await.is_err() {
250 let file = tokio::fs::File::open(staged).await?;
251 let length = file.metadata().await?.len();
252
253 // One request while one request will carry it, which is every
254 // object a store normally sees, and parts when it will not. The
255 // split is here rather than always going in parts because the
256 // single write is one round trip and needs no cleanup if it fails.
257 if length > multipart::SINGLE_PUT_CEILING {
258 drop(file);
259 multipart::put(&self.keys, &Self::content_key(oid), staged, length).await?;
260 } else {
261 let stream = tokio_util::io::ReaderStream::new(file);
262
263 self.keys
264 .put(
265 &Self::content_key(oid),
266 reqwest::Body::wrap_stream(stream),
267 length,
268 )
269 .await?;
270 }
271 }
272
273 self.keys
274 .put(
275 &Self::marker_key(ns, oid),
276 reqwest::Body::from(Vec::new()),
277 0,
278 )
279 .await
280 }
281
282 // What an interrupted upload leaves behind. A client can negotiate, PUT the
283 // object, and never report it: the bytes sit under its own upload key and
284 // nothing else will ever look at them. The local path has had a reclaimer for
285 // this since the beginning, and a bucket had none, so the cost was unbounded
286 // over time and invisible.
287 pub async fn reclaim_incoming(&self, older_than: Duration) -> Result<Reclaimed, Error> {
288 let mut reclaimed = Reclaimed::default();
289
290 for entry in self.keys.entries(".incoming/").await? {
291 // A slow client on a bad connection is not an abandoned one.
292 if entry.age().is_none_or(|age| age < older_than) {
293 continue;
294 }
295
296 if self.keys.delete(&entry.key).await.is_ok() {
297 reclaimed.files += 1;
298 reclaimed.bytes += entry.size;
299 }
300 }
301
302 Ok(reclaimed)
303 }
304
305 // Collection, with the marker keyspace standing in for the link count a
306 // filesystem keeps. A repository's marker is its claim on the bytes, and the
307 // bytes go when the last claim does.
308 //
309 // Everything hard here is one question: does any *other* repository still
310 // claim this object? A marker is `{org}/{repo}/.../{oid}`, so the oid is the
311 // suffix and the org and repo that would make a prefix are exactly what is
312 // unknown. The claim index turns that into one prefix listing per object. A
313 // bucket that predates the index has to be read whole instead, and that pass
314 // builds the index as it goes, so it is paid once rather than every sweep.
315 pub async fn sweep(
316 &self,
317 ns: &Namespace,
318 retained: &std::collections::HashSet<String>,
319 grace: Duration,
320 dry_run: bool,
321 ) -> Result<crate::storage::SweepReport, Error> {
322 if refs::ready(&self.keys).await {
323 self.sweep_indexed(ns, retained, grace, dry_run).await
324 } else {
325 self.sweep_whole_bucket(ns, retained, grace, dry_run).await
326 }
327 }
328
329 // The last question asked before bytes go, and the reason the index is read
330 // twice for one object.
331 //
332 // Between deciding an object is unclaimed and deleting it, another repository
333 // can push the same digest. It finds the content already there, skips the
334 // upload, and writes a claim, so deleting now leaves it holding a marker
335 // pointing at nothing, which its client meets as a missing object on the next
336 // pull.
337 //
338 // A push writes its ref before it so much as looks at the content, so a claim
339 // that landed at any moment before this question is one this sees. What is
340 // left is the width of a single request, between reading this answer and the
341 // delete that follows it. Closing that needs a lease the deleting side takes
342 // and every push waits on, which is a round trip on the hot path bought
343 // against a window this narrow, and it is not obviously the right trade.
344 async fn claimed_since(&self, ns: &Namespace, oid: &str) -> bool {
345 if refs::claimed_by_another(&self.keys, ns, oid).await {
346 tracing::info!(
347 oid,
348 "another repository claimed this object while it was being collected, so its bytes \
349 stay"
350 );
351
352 return true;
353 }
354
355 false
356 }
357
358 // The markers this repository is allowed to drop. Retained is what the client
359 // says it still needs; the grace window is what keeps a push still in flight
360 // from being read as an abandoned object.
361 fn droppable(
362 mine: Vec<(keyspace::Entry, String)>,
363 retained: &std::collections::HashSet<String>,
364 grace: Duration,
365 report: &mut crate::storage::SweepReport,
366 ) -> Vec<(keyspace::Entry, String)> {
367 mine.into_iter()
368 .filter(|(entry, oid)| {
369 if retained.contains(oid) {
370 return false;
371 }
372
373 if entry.age().is_none_or(|age| age < grace) {
374 report.within_grace += 1;
375 return false;
376 }
377
378 report.swept += 1;
379 true
380 })
381 .collect()
382 }
383
384 // The cost this exists to avoid: one listing of this repository's own prefix,
385 // then one listing of a short index prefix per object actually being dropped.
386 // Nothing here is proportional to the size of the bucket.
387 async fn sweep_indexed(
388 &self,
389 ns: &Namespace,
390 retained: &std::collections::HashSet<String>,
391 grace: Duration,
392 dry_run: bool,
393 ) -> Result<crate::storage::SweepReport, Error> {
394 let listing = self.keys.listing(&Self::own_prefix(ns)).await;
395 let mut report = crate::storage::SweepReport {
396 dry_run,
397 incomplete: !listing.complete,
398 ..Default::default()
399 };
400
401 let mine = listing
402 .entries
403 .into_iter()
404 .filter_map(|entry| {
405 let oid = entry.key.rsplit('/').next()?.to_owned();
406 crate::storage::LocalStore::validate_oid(&oid).ok()?;
407 Some((entry, oid))
408 })
409 .collect();
410
411 for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
412 let frees = !refs::claimed_by_another(&self.keys, ns, &oid).await;
413
414 if dry_run {
415 if frees {
416 report.bytes += self.size_of(&oid).await.unwrap_or_default();
417 }
418 continue;
419 }
420
421 self.keys.delete(&entry.key).await?;
422
423 // After the marker, never before. A failure between the two has to
424 // leave a ref with no claim behind it, which costs an object nobody
425 // reads, rather than a claim with no ref, which would let the next
426 // sweep free bytes this repository still holds.
427 if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
428 tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
429 }
430
431 if frees && !self.claimed_since(ns, &oid).await {
432 // Asked before the delete, because afterwards there is nothing
433 // left to ask.
434 let size = self.size_of(&oid).await.unwrap_or_default();
435
436 if self.keys.delete(&Self::content_key(&oid)).await? {
437 report.bytes += size;
438 }
439 }
440 }
441
442 Ok(report)
443 }
444
445 // What a bucket with no index costs, and what builds one.
446 //
447 // One listing of the whole bucket answers all three questions at once: which
448 // markers this repository holds, which oids any other repository still
449 // claims, and how big each content object is. Asked separately they would
450 // cost a request per object, which on a bucket is the difference between a
451 // collection an operator runs and one they read about.
452 //
453 // A listing that did not finish is the dangerous case. It cannot be used to
454 // conclude that nothing references an object, because the reference may sit
455 // in the pages that never arrived. So an incomplete listing still drops this
456 // repository's markers, which the retained set alone decides, and leaves
457 // every content key exactly where it is.
458 async fn sweep_whole_bucket(
459 &self,
460 ns: &Namespace,
461 retained: &std::collections::HashSet<String>,
462 grace: Duration,
463 dry_run: bool,
464 ) -> Result<crate::storage::SweepReport, Error> {
465 let listing = self.keys.listing("").await;
466 let mut report = crate::storage::SweepReport {
467 dry_run,
468 incomplete: !listing.complete,
469 ..Default::default()
470 };
471
472 let ours = Self::own_prefix(ns);
473 let mut markers = Vec::new();
474 let mut mine = Vec::new();
475 let mut claimed_elsewhere = std::collections::HashSet::new();
476 let mut sizes = std::collections::HashMap::new();
477
478 for entry in listing.entries {
479 if let Some(rest) = entry.key.strip_prefix(".content/") {
480 if let Some(oid) = rest.rsplit('/').next() {
481 sizes.insert(oid.to_owned(), entry.size);
482 }
483 continue;
484 }
485
486 // Locks live at `.locks/{org}/{repo}/{id}`, so they never match the
487 // marker prefix and are never swept. Skipped explicitly all the same:
488 // falling through would file every lock id in the claimed set, and an
489 // object whose digest happened to equal a lock id would then never be
490 // collected. The odds are absurd today and the line costs nothing,
491 // but the code should not depend on ids and digests never colliding.
492 //
493 // The index is skipped for a sharper reason than caution:
494 // `.refs/{oid}/{org}/{repo}` ends in a repository name, so reading one
495 // as a marker would file that name as an oid somebody claims.
496 if entry.key.starts_with(".incoming/")
497 || entry.key.starts_with(".locks/")
498 || entry.key.starts_with(".refs/")
499 || entry.key.starts_with(".probe/")
500 {
501 continue;
502 }
503
504 let Some(oid) = entry.key.rsplit('/').next().map(str::to_owned) else {
505 continue;
506 };
507
508 markers.push(entry.key.clone());
509
510 if entry.key.starts_with(&ours) {
511 mine.push((entry, oid));
512 } else {
513 claimed_elsewhere.insert(oid);
514 }
515 }
516
517 // Before anything is deleted, so the index never gains a ref for a marker
518 // this sweep is about to drop. Built from the listing already paid for,
519 // and only when that listing finished: an index built from half a bucket
520 // would be missing holders, which is the one direction it must never
521 // drift in.
522 //
523 // A failure is not fatal. The listing above has already answered the
524 // question correctly on its own, so collection proceeds and the next
525 // sweep reads the bucket again.
526 if !dry_run
527 && listing.complete
528 && let Err(error) = refs::backfill(&self.keys, &markers).await
529 {
530 tracing::warn!(
531 %error,
532 "the claim index could not be built, so the next sweep reads the bucket again"
533 );
534 }
535
536 for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
537 // Only what this call actually frees is counted. Another repository
538 // holding the same bytes means dropping this marker frees nothing,
539 // and a dry run that said otherwise would promise space it cannot
540 // deliver.
541 let frees = listing.complete && !claimed_elsewhere.contains(&oid);
542 let size = sizes.get(&oid).copied().unwrap_or_default();
543
544 if dry_run {
545 if frees {
546 report.bytes += size;
547 }
548 continue;
549 }
550
551 self.keys.delete(&entry.key).await?;
552
553 if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
554 tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
555 }
556
557 // Counted only when this call is the one that removed them, so two
558 // repositories letting go at once cannot each claim the same space.
559 // The listing that decided `frees` was taken before any of these
560 // deletes, so it is the stalest answer there is and the index gets
561 // the last word.
562 if frees
563 && !self.claimed_since(ns, &oid).await
564 && self.keys.delete(&Self::content_key(&oid)).await?
565 {
566 report.bytes += size;
567 }
568 }
569
570 Ok(report)
571 }
572
573 // What the bucket holds for this repository, counted from its markers and
574 // the content they point at. The markers are empty, so their own size says
575 // nothing — this is a listing plus one head per object, which is why the
576 // figure is cached the same way the local one is.
577 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
578 let prefix = Self::own_prefix(ns);
579 let mut objects = 0;
580 let mut bytes = 0;
581
582 for oid in self.list(&prefix).await {
583 objects += 1;
584 bytes += self.size_of(&oid).await.unwrap_or_default();
585 }
586
587 (objects, bytes)
588 }
589
590 async fn list(&self, prefix: &str) -> Vec<String> {
591 // A capacity figure that silently reads zero is worse than one that is
592 // missing, because it looks like an answer.
593 let keys = match self.keys.keys(prefix).await {
594 Ok(keys) => keys,
595 Err(error) => {
596 tracing::warn!(%error, "the object store could not be listed");
597 return Vec::new();
598 }
599 };
600
601 keys.into_iter()
602 .filter_map(|key| key.rsplit('/').next().map(str::to_owned))
603 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
604 .collect()
605 }
606}
607
608#[cfg(test)]
609pub(crate) mod tests;