OXYGENHUB
# 614f55e14eee oxygen / crates / oxy-strata / src / lib.rs
15.5 KiB · Rust · last changed Strata: S3, Google Cloud Storage and Azure Blob Storage stores 1 month ago source blame history raw
8d803563 Andy McCall ↶ 1
//! Oxygen Strata: repositories kept on cloud object storage (docs/stores.md).
2
//!
3
//! This crate gives `oxy` the stores that need an HTTP client and the
4
//! providers' signing: Amazon S3 (`s3://BUCKET/PREFIX`), Google Cloud
5
//! Storage (`gs://BUCKET/PREFIX`) and Azure Blob Storage
6
//! (`az://ACCOUNT/CONTAINER/PREFIX`), each as a [`Store`] over the
7
//! `object_store` crate. Credentials come from each provider's standard
8
//! places, with nothing oxy-specific to set up; a URL may carry settings
9
//! as query parameters (`?profile=work&region=eu-west-2`), which is where
10
//! `oxy remote add --profile` keeps them.
11
//!
12
//! [`register`] hands these stores to oxy-core's remote opener; a program
13
//! that links this crate calls it once at start.
14
15
mod aws;
16
mod azure;
17
mod gcp;
18
mod ini;
19
20
use futures::TryStreamExt;
21
use object_store::path::Path as ObjectPath;
22
use object_store::{
23
    GetOptions, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload, UpdateVersion,
24
};
25
use oxy_core::strata::{check_key, Entry, Error, Result, Store, Version};
26
use std::collections::BTreeMap;
27
use std::future::Future;
28
use std::ops::Range;
29
use std::sync::{Arc, Mutex};
30
31
/// Make the cloud stores available to `oxy_core::protocol::open_remote`.
32
/// Safe to call more than once.
33
pub fn register() {
34
    // object_store's reqwest is built with rustls and no bundled crypto
35
    // provider; oxy links ring already, so that is the one to install.
36
    let _ = rustls::crypto::ring::default_provider().install_default();
37
    oxy_core::protocol::register_store_opener(open_url);
38
}
39
40
fn open_url(
41
    url: &str,
42
    _options: &oxy_core::protocol::RemoteOptions,
43
) -> Option<Result<Box<dyn Store>>> {
44
    let scheme = url.split_once("://")?.0;
45
    if !matches!(scheme, "s3" | "gs" | "az") {
46
        return None;
47
    }
48
    Some(CloudStore::open(url).map(|s| Box::new(s) as Box<dyn Store>))
49
}
50
51
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
52
#[derive(Clone, Debug, PartialEq, Eq)]
53
pub struct StoreUrl {
54
    pub scheme: String,
55
    /// The bucket (S3, GCS) or the storage account (Azure).
56
    pub host: String,
57
    /// The path under the host, without leading or trailing slashes; for
58
    /// Azure the container is its first segment.
59
    pub path: String,
60
    pub params: BTreeMap<String, String>,
61
}
62
63
impl StoreUrl {
64
    pub fn parse(url: &str) -> Result<StoreUrl> {
65
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
66
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
67
        let (rest, query) = match rest.split_once('?') {
68
            Some((r, q)) => (r, Some(q)),
69
            None => (rest, None),
70
        };
71
        let (host, path) = match rest.split_once('/') {
72
            Some((h, p)) => (h, p.trim_matches('/')),
73
            None => (rest, ""),
74
        };
75
        if host.is_empty() {
76
            return Err(bad("names no bucket"));
77
        }
78
        let mut params = BTreeMap::new();
79
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
80
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
81
            params.insert(k.to_string(), percent_decode(v));
82
        }
83
        Ok(StoreUrl {
84
            scheme: scheme.to_string(),
85
            host: host.to_string(),
86
            path: path.to_string(),
87
            params,
88
        })
89
    }
90
91
    /// The URL without its parameters, for messages.
92
    pub fn plain(&self) -> String {
93
        if self.path.is_empty() {
94
            format!("{}://{}", self.scheme, self.host)
95
        } else {
96
            format!("{}://{}/{}", self.scheme, self.host, self.path)
97
        }
98
    }
99
100
    pub fn param(&self, name: &str) -> Option<&str> {
101
        self.params.get(name).map(String::as_str)
102
    }
103
}
104
105
fn percent_decode(s: &str) -> String {
106
    let bytes = s.as_bytes();
107
    let mut out = Vec::with_capacity(bytes.len());
108
    let mut i = 0;
109
    while i < bytes.len() {
110
        if bytes[i] == b'%' && i + 2 < bytes.len() {
111
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
112
                out.push(v);
113
                i += 3;
114
                continue;
115
            }
116
        }
117
        out.push(bytes[i]);
118
        i += 1;
119
    }
120
    String::from_utf8_lossy(&out).into_owned()
121
}
122
123
/// A store on a cloud provider: the provider's client, the prefix every
124
/// key goes under, and a runtime to run the client on.
125
pub struct CloudStore {
126
    inner: Arc<dyn ObjectStore>,
127
    prefix: String,
128
    name: String,
129
    hint: &'static str,
130
    runtime: tokio::runtime::Runtime,
131
    /// One call at a time: the runtime runs on the calling thread.
132
    turn: Mutex<()>,
133
}
134
135
impl CloudStore {
136
    /// Open the store a URL names. Nothing is sent yet: credentials are
137
    /// resolved here, but the bucket is first spoken to on the first call.
138
    pub fn open(url: &str) -> Result<CloudStore> {
139
        let parsed = StoreUrl::parse(url)?;
140
        let runtime = tokio::runtime::Builder::new_current_thread()
141
            .enable_all()
142
            .build()
143
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
144
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
145
            match parsed.scheme.as_str() {
146
                "s3" => (
147
                    Arc::new(aws::open(&parsed, &runtime)?),
148
                    parsed.path.clone(),
149
                    aws::HINT,
150
                ),
151
                "gs" => (
152
                    Arc::new(gcp::open(&parsed)?),
153
                    parsed.path.clone(),
154
                    gcp::HINT,
155
                ),
156
                "az" => {
157
                    let (store, prefix) = azure::open(&parsed)?;
158
                    (Arc::new(store), prefix, azure::HINT)
159
                }
160
                other => {
161
                    return Err(Error::Backend(format!(
162
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
163
                    )))
164
                }
165
            };
166
        Ok(CloudStore {
167
            inner,
168
            prefix,
169
            name: parsed.plain(),
170
            hint,
171
            runtime,
172
            turn: Mutex::new(()),
173
        })
174
    }
175
176
    fn block<F: Future>(&self, f: F) -> F::Output {
177
        let _turn = self.turn.lock().unwrap_or_else(|p| p.into_inner());
178
        self.runtime.block_on(f)
179
    }
180
181
    fn path(&self, key: &str) -> Result<ObjectPath> {
182
        check_key(key)?;
183
        let full = if self.prefix.is_empty() {
184
            key.to_string()
185
        } else {
186
            format!("{}/{key}", self.prefix)
187
        };
188
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
189
    }
190
191
    /// Our error for the provider's, with what to do next where it helps.
192
    fn map(&self, e: object_store::Error, key: &str) -> Error {
193
        use object_store::Error as E;
194
        match e {
195
            E::NotFound { .. } => Error::NotFound(key.to_string()),
196
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
197
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
198
            other => {
199
                let text = other.to_string();
200
                let lower = text.to_ascii_lowercase();
201
                let advice = if lower.contains("403")
202
                    || lower.contains("accessdenied")
203
                    || lower.contains("forbidden")
204
                    || lower.contains("authorizationpermissionmismatch")
205
                {
206
                    format!(
207
                        ": access denied; the credentials in use may not do this on {}: {}",
208
                        self.name, self.hint
209
                    )
210
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
211
                    format!(": no usable credentials: {}", self.hint)
212
                } else if lower.contains("nosuchbucket")
213
                    || lower.contains("bucketnotfound")
214
                    || lower.contains("containernotfound")
215
                {
216
                    format!(
217
                        ": {} does not exist, or the credentials cannot see it",
218
                        self.name
219
                    )
220
                } else if lower.contains("dns")
221
                    || lower.contains("connect")
222
                    || lower.contains("timed out")
223
                {
224
                    ": cannot reach the service; check the network, the endpoint and the region"
225
                        .to_string()
226
                } else {
227
                    String::new()
228
                };
229
                Error::Backend(format!("{}: {text}{advice}", self.name))
230
            }
231
        }
232
    }
233
234
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
235
        Version(format!(
236
            "{}\t{}",
237
            e_tag.unwrap_or_default(),
238
            version.unwrap_or_default()
239
        ))
240
    }
241
242
    fn update_version(v: &Version) -> UpdateVersion {
243
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
244
        UpdateVersion {
245
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
246
            version: (!version.is_empty()).then(|| version.to_string()),
247
        }
248
    }
249
}
250
251
impl Store for CloudStore {
252
    fn describe(&self) -> String {
253
        self.name.clone()
254
    }
255
256
    fn has(&self, key: &str) -> Result<bool> {
257
        let path = self.path(key)?;
258
        match self.block(self.inner.head(&path)) {
259
            Ok(_) => Ok(true),
260
            Err(object_store::Error::NotFound { .. }) => Ok(false),
261
            Err(e) => Err(self.map(e, key)),
262
        }
263
    }
264
265
    fn get(&self, key: &str) -> Result<Vec<u8>> {
266
        let path = self.path(key)?;
267
        self.block(async {
268
            let r = self.inner.get(&path).await?;
269
            r.bytes().await
270
        })
271
        .map(|b| b.to_vec())
272
        .map_err(|e| self.map(e, key))
273
    }
274
275
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
276
        let path = self.path(key)?;
277
        if range.start > range.end {
278
            return Err(Error::Backend(format!(
279
                "{key}: bytes {}..{} is not a range",
280
                range.start, range.end
281
            )));
282
        }
283
        if range.is_empty() {
284
            return Ok(Vec::new());
285
        }
286
        let want = (range.end - range.start) as usize;
287
        let bytes = self
288
            .block(self.inner.get_range(&path, range.clone()))
289
            .map_err(|e| self.map(e, key))?;
290
        if bytes.len() != want {
291
            return Err(Error::Backend(format!(
292
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
293
                range.start,
294
                range.end,
295
                bytes.len()
296
            )));
297
        }
298
        Ok(bytes.to_vec())
299
    }
300
301
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
302
        let path = self.path(key)?;
303
        let opts = PutOptions {
304
            mode: PutMode::Create,
305
            ..Default::default()
306
        };
307
        self.block(
308
            self.inner
309
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
310
        )
311
        .map(|_| ())
312
        .map_err(|e| self.map(e, key))
313
    }
314
315
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
316
        // Providers list by whole path segments; the asked prefix may end
317
        // mid-segment, so list its directory and filter here.
318
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
319
        let full_dir = match (self.prefix.as_str(), dir) {
320
            ("", "") => None,
321
            ("", d) => Some(d.to_string()),
322
            (p, "") => Some(p.to_string()),
323
            (p, d) => Some(format!("{p}/{d}")),
324
        };
325
        let full_dir = full_dir
326
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
327
            .transpose()?;
328
        let metas: Vec<object_store::ObjectMeta> = self
329
            .block(self.inner.list(full_dir.as_ref()).try_collect())
330
            .map_err(|e| self.map(e, prefix))?;
331
        let strip = if self.prefix.is_empty() {
332
            String::new()
333
        } else {
334
            format!("{}/", self.prefix)
335
        };
336
        let mut out: Vec<Entry> = metas
337
            .into_iter()
338
            .filter_map(|m| {
339
                let loc = m.location.to_string();
340
                let key = loc.strip_prefix(&strip)?;
341
                key.starts_with(prefix).then(|| Entry {
342
                    key: key.to_string(),
343
                    size: m.size,
344
                })
345
            })
346
            .collect();
347
        out.sort_by(|a, b| a.key.cmp(&b.key));
348
        Ok(out)
349
    }
350
351
    fn delete(&self, key: &str) -> Result<()> {
352
        let path = self.path(key)?;
353
        match self.block(self.inner.delete(&path)) {
354
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
355
            Err(e) => Err(self.map(e, key)),
356
        }
357
    }
358
359
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
360
        let path = self.path(key)?;
361
        let got = self.block(async {
362
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
363
            let meta = r.meta.clone();
364
            let bytes = r.bytes().await?;
365
            Ok::<_, object_store::Error>((bytes, meta))
366
        });
367
        match got {
368
            Ok((bytes, meta)) => Ok(Some((
369
                bytes.to_vec(),
370
                Self::version_of(meta.e_tag, meta.version),
371
            ))),
372
            Err(object_store::Error::NotFound { .. }) => Ok(None),
373
            Err(e) => Err(self.map(e, key)),
374
        }
375
    }
376
377
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
378
        let path = self.path(key)?;
379
        let mode = match expected {
380
            None => PutMode::Create,
381
            Some(v) => PutMode::Update(Self::update_version(v)),
382
        };
383
        let opts = PutOptions {
384
            mode,
385
            ..Default::default()
386
        };
387
        let put = self
388
            .block(
389
                self.inner
390
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
391
            )
392
            .map_err(|e| match self.map(e, key) {
393
                // A record that exists when none was expected is a lost
394
                // race too, not a misuse of put_new.
395
                Error::Exists(k) => Error::Conflict(k),
396
                other => other,
397
            })?;
398
        Ok(Self::version_of(put.e_tag, put.version))
399
    }
400
}
401
402
#[cfg(test)]
403
mod tests {
404
    use super::*;
405
406
    #[test]
407
    fn urls_parse_with_their_parameters() {
408
        let u =
409
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
410
        assert_eq!(u.scheme, "s3");
411
        assert_eq!(u.host, "my-bucket");
412
        assert_eq!(u.path, "repos/oxygen");
413
        assert_eq!(u.param("profile"), Some("work"));
414
        assert_eq!(u.param("region"), Some("eu-west-2"));
415
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
416
        let u = StoreUrl::parse("gs://bucket").unwrap();
417
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
418
        assert_eq!(u.plain(), "gs://bucket");
419
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
420
            .unwrap();
421
        assert_eq!(u.path, "container/a/b");
422
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
423
        assert!(StoreUrl::parse("s3://").is_err());
424
        assert!(StoreUrl::parse("nonsense").is_err());
425
    }
426
427
    #[test]
428
    fn versions_carry_both_an_etag_and_a_generation() {
429
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
430
        let u = CloudStore::update_version(&v);
431
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
432
        assert_eq!(u.version.as_deref(), Some("17"));
433
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
434
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
435
    }
436
}