lfsx_server/storage/
s3.rs1use std::time::Duration;
2
3use axum::body::Bytes;
4use futures_util::Stream;
5use rusty_s3::actions::{GetObject, HeadObject, ListObjectsV2, PutObject, S3Action};
6use rusty_s3::{Bucket, Credentials, UrlStyle};
7
8use crate::error::Error;
9use crate::namespace::Namespace;
10
11#[derive(Clone)]
18pub struct S3Store {
19 bucket: Bucket,
20 credentials: Credentials,
21 client: reqwest::Client,
22 presign: Duration,
23}
24
25pub struct S3Config {
26 pub endpoint: String,
27 pub bucket: String,
28 pub region: String,
29 pub access_key: String,
30 pub secret_key: String,
31 pub path_style: bool,
32}
33
34impl S3Store {
35 pub fn new(config: &S3Config) -> Result<Self, Error> {
36 let style = if config.path_style {
37 UrlStyle::Path
38 } else {
39 UrlStyle::VirtualHost
40 };
41
42 let bucket = Bucket::new(
43 config
44 .endpoint
45 .parse()
46 .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
47 style,
48 config.bucket.clone(),
49 config.region.clone(),
50 )
51 .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
52
53 Ok(Self {
54 bucket,
55 credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
56 client: reqwest::Client::new(),
57 presign: Duration::from_secs(1800),
58 })
59 }
60
61 fn content_key(oid: &str) -> String {
62 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
63 }
64
65 fn marker_key(ns: &Namespace, oid: &str) -> String {
66 format!(
67 "{}/{}/{}/{}/{oid}",
68 ns.org(),
69 ns.repo(),
70 &oid[0..2],
71 &oid[2..4]
72 )
73 }
74
75 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
76 if crate::storage::LocalStore::validate_oid(oid).is_err() {
77 return false;
78 }
79
80 self.head(&Self::marker_key(ns, oid)).await.is_ok()
81 }
82
83 async fn head(&self, key: &str) -> Result<u64, Error> {
88 let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
89 let url = action.sign(self.presign);
90
91 let response = self.client.head(url).send().await.map_err(|_| {
92 Error::Storage(std::io::Error::other("the object store is unreachable"))
93 })?;
94
95 if !response.status().is_success() {
96 return Err(Error::NotFound);
97 }
98
99 response
103 .headers()
104 .get(reqwest::header::CONTENT_LENGTH)
105 .and_then(|value| value.to_str().ok())
106 .and_then(|value| value.parse().ok())
107 .ok_or_else(|| {
108 Error::Storage(std::io::Error::other(
109 "the object store gave no object size",
110 ))
111 })
112 }
113
114 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
115 crate::storage::LocalStore::validate_oid(oid)?;
120
121 self.head(&Self::content_key(oid)).await
122 }
123
124 pub async fn read(
130 &self,
131 oid: &str,
132 start: u64,
133 length: u64,
134 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
135 crate::storage::LocalStore::validate_oid(oid)?;
136
137 let key = Self::content_key(oid);
138 let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
139 let url = action.sign(self.presign);
140
141 let response = self
142 .client
143 .get(url)
144 .header(
145 reqwest::header::RANGE,
146 format!("bytes={start}-{}", start + length.saturating_sub(1)),
147 )
148 .send()
149 .await
150 .map_err(|_| {
151 Error::Storage(std::io::Error::other("the object store is unreachable"))
152 })?;
153
154 if !response.status().is_success() {
155 return Err(Error::NotFound);
156 }
157
158 Ok(response.bytes_stream())
159 }
160
161 pub fn presigned_download(&self, oid: &str) -> String {
162 let key = Self::content_key(oid);
163
164 GetObject::new(&self.bucket, Some(&self.credentials), &key)
165 .sign(self.presign)
166 .to_string()
167 }
168
169 async fn put(&self, key: &str, body: reqwest::Body) -> Result<(), Error> {
170 let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
171 let url = action.sign(self.presign);
172
173 let response = self.client.put(url).body(body).send().await.map_err(|_| {
174 Error::Storage(std::io::Error::other("the object store is unreachable"))
175 })?;
176
177 response
178 .error_for_status()
179 .map_err(|error| Error::Storage(std::io::Error::other(error)))?;
180
181 Ok(())
182 }
183
184 pub async fn store(
194 &self,
195 ns: &Namespace,
196 oid: &str,
197 staged: &std::path::Path,
198 ) -> Result<(), Error> {
199 crate::storage::LocalStore::validate_oid(oid)?;
200
201 if self.head(&Self::content_key(oid)).await.is_err() {
202 let file = tokio::fs::File::open(staged).await?;
203 let stream = tokio_util::io::ReaderStream::new(file);
204
205 self.put(&Self::content_key(oid), reqwest::Body::wrap_stream(stream))
206 .await?;
207 }
208
209 self.put(&Self::marker_key(ns, oid), reqwest::Body::from(Vec::new()))
210 .await
211 }
212
213 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
218 let prefix = format!("{}/{}/", ns.org(), ns.repo());
219 let mut objects = 0;
220 let mut bytes = 0;
221
222 for oid in self.list(&prefix).await {
223 objects += 1;
224 bytes += self.size_of(&oid).await.unwrap_or_default();
225 }
226
227 (objects, bytes)
228 }
229
230 async fn list(&self, prefix: &str) -> Vec<String> {
231 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
232 action.with_prefix(prefix);
233
234 let response = match self.client.get(action.sign(self.presign)).send().await {
238 Ok(response) => response,
239 Err(error) => {
240 tracing::warn!(%error, "the object store could not be listed");
241 return Vec::new();
242 }
243 };
244
245 let body = match response.text().await {
246 Ok(body) => body,
247 Err(error) => {
248 tracing::warn!(%error, "the listing could not be read");
249 return Vec::new();
250 }
251 };
252
253 let listing = match ListObjectsV2::parse_response(&body) {
254 Ok(listing) => listing,
255 Err(error) => {
256 tracing::warn!(%error, "the listing could not be parsed");
257 return Vec::new();
258 }
259 };
260
261 listing
262 .contents
263 .into_iter()
264 .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
265 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
266 .collect()
267 }
268}
269
270#[cfg(test)]
271pub(crate) mod tests;