OXYGENHUB
◆ v0.4.5 oxygen / crates / oxy-strata / src / lib.rs
33.6 KiB · Rust · last changed Cloud stores remember a missing key for 30 s, until this process writes it 18 days 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;
2cd40ea1 Andy McCall ↶ 18
mod http;
8d803563 Andy McCall ↶ 19
mod ini;
20
21
use futures::TryStreamExt;
22
use object_store::path::Path as ObjectPath;
23
use object_store::{
24
    GetOptions, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload, UpdateVersion,
25
};
26
use oxy_core::strata::{check_key, Entry, Error, Result, Store, Version};
27
use std::collections::BTreeMap;
28
use std::future::Future;
29
use std::ops::Range;
30
use std::sync::{Arc, Mutex};
31
32
/// Make the cloud stores available to `oxy_core::protocol::open_remote`.
33
/// Safe to call more than once.
34
pub fn register() {
35
    // object_store's reqwest is built with rustls and no bundled crypto
36
    // provider; oxy links ring already, so that is the one to install.
37
    let _ = rustls::crypto::ring::default_provider().install_default();
38
    oxy_core::protocol::register_store_opener(open_url);
39
}
40
41
fn open_url(
42
    url: &str,
43
    _options: &oxy_core::protocol::RemoteOptions,
44
) -> Option<Result<Box<dyn Store>>> {
45
    let scheme = url.split_once("://")?.0;
46
    if !matches!(scheme, "s3" | "gs" | "az") {
47
        return None;
48
    }
7026bd7d Andy McCall ↶ 49
    let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner());
50
    let given = lookup.and_then(|f| f(url));
2ddd4716 Andy McCall ↶ 51
    Some(shared(url, given).map(|s| Box::new(Shared(s)) as Box<dyn Store>))
8d803563 Andy McCall ↶ 52
}
53
2ddd4716 Andy McCall ↶ 54
/// One client per store and credentials, kept for the life of the process
55
/// (oxygenhub#234): a server opens a repository for every request, and a
56
/// new client each time meant a new runtime, new connections and signing
57
/// in to the provider again before the first byte.
58
type OpenStores = std::collections::HashMap<(String, Option<Credentials>), Arc<CloudStore>>;
59
60
fn shared(url: &str, given: Option<Credentials>) -> Result<Arc<CloudStore>> {
61
    static OPEN: Mutex<Option<OpenStores>> = Mutex::new(None);
62
    let key = (url.to_string(), given);
63
    let mut open = OPEN.lock().unwrap_or_else(|p| p.into_inner());
64
    let map = open.get_or_insert_with(Default::default);
65
    if let Some(s) = map.get(&key) {
66
        return Ok(s.clone());
67
    }
68
    let store = Arc::new(CloudStore::open_with(url, key.1.as_ref())?);
69
    map.insert(key, store.clone());
70
    Ok(store)
71
}
72
73
/// A shared [`CloudStore`] as a [`Store`].
74
struct Shared(Arc<CloudStore>);
75
76
impl Store for Shared {
77
    fn describe(&self) -> String {
78
        self.0.describe()
79
    }
80
    fn has(&self, key: &str) -> Result<bool> {
81
        self.0.has(key)
82
    }
83
    fn get(&self, key: &str) -> Result<Vec<u8>> {
84
        self.0.get(key)
85
    }
86
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
87
        self.0.get_range(key, range)
88
    }
89
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
90
        self.0.put_new(key, bytes)
91
    }
92
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
93
        self.0.list(prefix)
94
    }
95
    fn delete(&self, key: &str) -> Result<()> {
96
        self.0.delete(key)
97
    }
98
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
99
        self.0.read_ref(key)
100
    }
101
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
102
        self.0.write_ref(key, bytes, expected)
103
    }
104
}
105
106
static OPENS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
107
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
108
static MICROS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1e0fbb47 Andy McCall ↶ 109
static BY_OP: [std::sync::atomic::AtomicU64; Op::ALL.len()] =
110
    [const { std::sync::atomic::AtomicU64::new(0) }; Op::ALL.len()];
2ddd4716 Andy McCall ↶ 111
1e0fbb47 Andy McCall ↶ 112
/// The kinds of request a cloud store makes, counted apart so a server can
113
/// tell which of them its time goes to.
114
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115
pub enum Op {
116
    /// `has` that found the object.
117
    Has,
118
    /// `has` that did not: an absent object is never cached.
119
    HasMissing,
120
    Get,
121
    Range,
122
    Put,
123
    List,
124
    Delete,
125
    ReadRef,
126
    WriteRef,
127
}
128
129
impl Op {
130
    pub const ALL: [Op; 9] = [
131
        Op::Has,
132
        Op::HasMissing,
133
        Op::Get,
134
        Op::Range,
135
        Op::Put,
136
        Op::List,
137
        Op::Delete,
138
        Op::ReadRef,
139
        Op::WriteRef,
140
    ];
141
142
    pub fn name(self) -> &'static str {
143
        match self {
144
            Op::Has => "has",
145
            Op::HasMissing => "has_missing",
146
            Op::Get => "get",
147
            Op::Range => "range",
148
            Op::Put => "put",
149
            Op::List => "list",
150
            Op::Delete => "delete",
151
            Op::ReadRef => "read_ref",
152
            Op::WriteRef => "write_ref",
153
        }
154
    }
155
}
156
157
fn counted(op: Op) {
158
    BY_OP[op as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed);
159
}
160
2ddd4716 Andy McCall ↶ 161
/// What the cloud stores have done since the process started: clients
162
/// opened, requests made and the time spent in them, for a server to show
163
/// (a hub's /health).
164
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
165
pub struct Stats {
166
    pub opens: u64,
167
    pub calls: u64,
168
    pub millis: u64,
1e0fbb47 Andy McCall ↶ 169
    /// Requests by kind, in [`Op::ALL`]'s order.
170
    pub by_op: [u64; Op::ALL.len()],
2ddd4716 Andy McCall ↶ 171
}
172
1e0fbb47 Andy McCall ↶ 173
impl Stats {
174
    /// Each kind of request with how many were made.
175
    pub fn ops(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
176
        Op::ALL
177
            .iter()
178
            .map(|op| (op.name(), self.by_op[*op as usize]))
179
    }
180
}
181
2ddd4716 Andy McCall ↶ 182
pub fn stats() -> Stats {
183
    use std::sync::atomic::Ordering::Relaxed;
184
    Stats {
185
        opens: OPENS.load(Relaxed),
186
        calls: CALLS.load(Relaxed),
187
        millis: MICROS.load(Relaxed) / 1000,
1e0fbb47 Andy McCall ↶ 188
        by_op: std::array::from_fn(|i| BY_OP[i].load(Relaxed)),
2ddd4716 Andy McCall ↶ 189
    }
190
}
191
7026bd7d Andy McCall ↶ 192
/// Credentials a program keeps itself and hands over, instead of the
193
/// provider's usual places: a hub keeps each repository's sealed. When a
194
/// store is opened with them, nothing from the environment or the
195
/// provider's files is used.
2ddd4716 Andy McCall ↶ 196
#[derive(Clone, PartialEq, Eq, Hash)]
7026bd7d Andy McCall ↶ 197
pub enum Credentials {
198
    /// An S3 access key.
199
    Aws {
200
        access_key_id: String,
201
        secret_access_key: String,
202
        session_token: Option<String>,
203
    },
204
    /// A Google Cloud service account key, as the JSON Google issues.
205
    Gcp { service_account_key: String },
206
    /// An Azure service principal.
207
    Azure {
208
        tenant_id: String,
209
        client_id: String,
210
        client_secret: String,
211
    },
212
}
213
214
impl std::fmt::Debug for Credentials {
215
    /// Names the kind and the public part only: never the secret.
216
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217
        match self {
218
            Credentials::Aws { access_key_id, .. } => {
219
                write!(f, "Aws({access_key_id}, secret hidden)")
220
            }
221
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
222
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
223
        }
224
    }
225
}
226
227
impl Credentials {
228
    /// The scheme of the stores these are for.
229
    pub fn scheme(&self) -> &'static str {
230
        match self {
231
            Credentials::Aws { .. } => "s3",
232
            Credentials::Gcp { .. } => "gs",
233
            Credentials::Azure { .. } => "az",
234
        }
235
    }
236
237
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
238
    pub fn to_json(&self) -> String {
239
        let v = match self {
240
            Credentials::Aws {
241
                access_key_id,
242
                secret_access_key,
243
                session_token,
244
            } => serde_json::json!({
245
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
246
                "session_token": session_token,
247
            }),
248
            Credentials::Gcp {
249
                service_account_key,
250
            } => {
251
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
252
            }
253
            Credentials::Azure {
254
                tenant_id,
255
                client_id,
256
                client_secret,
257
            } => serde_json::json!({
258
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
259
            }),
260
        };
261
        v.to_string()
262
    }
263
264
    pub fn from_json(text: &str) -> Result<Credentials> {
265
        let v: serde_json::Value = serde_json::from_str(text)
266
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
267
        let field = |k: &str| -> Result<String> {
268
            v.get(k)
269
                .and_then(|x| x.as_str())
270
                .filter(|x| !x.trim().is_empty())
271
                .map(|x| x.trim().to_string())
272
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
273
        };
274
        match v.get("kind").and_then(|k| k.as_str()) {
275
            Some("aws") => Ok(Credentials::Aws {
276
                access_key_id: field("access_key_id")?,
277
                secret_access_key: field("secret_access_key")?,
278
                session_token: field("session_token").ok(),
279
            }),
280
            Some("gcp") => {
281
                let key = field("service_account_key")?;
282
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
283
                    Error::Backend("the service account key is not the JSON Google issues".into())
284
                })?;
285
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
286
                    return Err(Error::Backend(
287
                        "the key is not a service account key (its type is not service_account)"
288
                            .into(),
289
                    ));
290
                }
291
                Ok(Credentials::Gcp {
292
                    service_account_key: key,
293
                })
294
            }
295
            Some("azure") => Ok(Credentials::Azure {
296
                tenant_id: field("tenant_id")?,
297
                client_id: field("client_id")?,
298
                client_secret: field("client_secret")?,
299
            }),
300
            _ => Err(Error::Backend(
301
                "credentials need a kind: aws, gcp or azure".into(),
302
            )),
303
        }
304
    }
305
}
306
307
/// Finds the credentials a program keeps for a store URL.
308
pub type CredentialsLookup = fn(&str) -> Option<Credentials>;
309
310
static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None);
311
312
/// Have stores opened by URL (as a repository's `storage.url` is) use the
313
/// credentials `lookup` finds for it, where it finds any.
314
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
315
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
316
}
317
8d803563 Andy McCall ↶ 318
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
319
#[derive(Clone, Debug, PartialEq, Eq)]
320
pub struct StoreUrl {
321
    pub scheme: String,
322
    /// The bucket (S3, GCS) or the storage account (Azure).
323
    pub host: String,
324
    /// The path under the host, without leading or trailing slashes; for
325
    /// Azure the container is its first segment.
326
    pub path: String,
327
    pub params: BTreeMap<String, String>,
328
}
329
330
impl StoreUrl {
331
    pub fn parse(url: &str) -> Result<StoreUrl> {
332
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
333
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
334
        let (rest, query) = match rest.split_once('?') {
335
            Some((r, q)) => (r, Some(q)),
336
            None => (rest, None),
337
        };
338
        let (host, path) = match rest.split_once('/') {
339
            Some((h, p)) => (h, p.trim_matches('/')),
340
            None => (rest, ""),
341
        };
342
        if host.is_empty() {
343
            return Err(bad("names no bucket"));
344
        }
345
        let mut params = BTreeMap::new();
346
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
347
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
348
            params.insert(k.to_string(), percent_decode(v));
349
        }
350
        Ok(StoreUrl {
351
            scheme: scheme.to_string(),
352
            host: host.to_string(),
353
            path: path.to_string(),
354
            params,
355
        })
356
    }
357
358
    /// The URL without its parameters, for messages.
359
    pub fn plain(&self) -> String {
360
        if self.path.is_empty() {
361
            format!("{}://{}", self.scheme, self.host)
362
        } else {
363
            format!("{}://{}/{}", self.scheme, self.host, self.path)
364
        }
365
    }
366
367
    pub fn param(&self, name: &str) -> Option<&str> {
368
        self.params.get(name).map(String::as_str)
369
    }
370
}
371
372
fn percent_decode(s: &str) -> String {
373
    let bytes = s.as_bytes();
374
    let mut out = Vec::with_capacity(bytes.len());
375
    let mut i = 0;
376
    while i < bytes.len() {
377
        if bytes[i] == b'%' && i + 2 < bytes.len() {
378
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
379
                out.push(v);
380
                i += 3;
381
                continue;
382
            }
383
        }
384
        out.push(bytes[i]);
385
        i += 1;
386
    }
387
    String::from_utf8_lossy(&out).into_owned()
388
}
389
390
/// A store on a cloud provider: the provider's client, the prefix every
391
/// key goes under, and a runtime to run the client on.
392
pub struct CloudStore {
393
    inner: Arc<dyn ObjectStore>,
394
    prefix: String,
395
    name: String,
396
    hint: &'static str,
2ddd4716 Andy McCall ↶ 397
    /// Always there until the store is dropped: two workers, so the
398
    /// requests of a shared store run side by side.
c9369ad8 Andy McCall ↶ 399
    runtime: Option<tokio::runtime::Runtime>,
8b53b935 Andy McCall ↶ 400
    /// Keys `has` found missing lately (see [`Misses`]).
401
    misses: Misses,
8d803563 Andy McCall ↶ 402
}
403
8b53b935 Andy McCall ↶ 404
/// How long a key found missing is taken as missing without asking again.
405
const MISS_FOR: std::time::Duration = std::time::Duration::from_secs(30);
406
407
/// Keys found missing in the last [`MISS_FOR`], so a server answering the
408
/// same question again and again does not send a request each time
409
/// (oxygenhub#228: a client's negotiate asked about ~190 objects the hub
410
/// lacked every 8 s, 45 ms a request). This process forgets a key the
411
/// moment it writes it, so its own writes are never hidden; another writer
412
/// to the same keys is seen within `MISS_FOR`.
413
#[derive(Default)]
414
struct Misses(Mutex<std::collections::HashMap<String, std::time::Instant>>);
415
416
impl Misses {
417
    /// At most this many kept; past it the oldest half is let go.
418
    const MAX: usize = 100_000;
419
420
    fn known(&self, key: &str) -> bool {
421
        let map = self.0.lock().unwrap_or_else(|p| p.into_inner());
422
        map.get(key).is_some_and(|t| t.elapsed() < MISS_FOR)
423
    }
424
425
    fn missed(&self, key: &str) {
426
        let mut map = self.0.lock().unwrap_or_else(|p| p.into_inner());
427
        if map.len() >= Self::MAX {
428
            map.retain(|_, t| t.elapsed() < MISS_FOR / 2);
429
            if map.len() >= Self::MAX {
430
                map.clear();
431
            }
432
        }
433
        map.insert(key.to_string(), std::time::Instant::now());
434
    }
435
436
    fn written(&self, key: &str) {
437
        self.0.lock().unwrap_or_else(|p| p.into_inner()).remove(key);
438
    }
439
}
440
c9369ad8 Andy McCall ↶ 441
impl Drop for CloudStore {
442
    /// Without waiting, so a store can be dropped inside a runtime too.
443
    fn drop(&mut self) {
444
        if let Some(rt) = self.runtime.take() {
445
            rt.shutdown_background();
446
        }
447
    }
448
}
449
8d803563 Andy McCall ↶ 450
impl CloudStore {
451
    /// Open the store a URL names. Nothing is sent yet: credentials are
452
    /// resolved here, but the bucket is first spoken to on the first call.
453
    pub fn open(url: &str) -> Result<CloudStore> {
7026bd7d Andy McCall ↶ 454
        Self::open_with(url, None)
455
    }
456
457
    /// [`CloudStore::open`] with credentials given, which must be for
458
    /// the URL's kind of store; then only they are used.
459
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
8d803563 Andy McCall ↶ 460
        let parsed = StoreUrl::parse(url)?;
7026bd7d Andy McCall ↶ 461
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
462
            return Err(Error::Backend(format!(
463
                "{}: the credentials given are for {}:// stores",
464
                parsed.plain(),
465
                c.scheme()
466
            )));
467
        }
468
        let aws_keys = match given {
469
            Some(Credentials::Aws {
470
                access_key_id,
471
                secret_access_key,
472
                session_token,
473
            }) => Some((
474
                access_key_id.as_str(),
475
                secret_access_key.as_str(),
476
                session_token.as_deref(),
477
            )),
478
            _ => None,
479
        };
480
        let gcp_key = match given {
481
            Some(Credentials::Gcp {
482
                service_account_key,
483
            }) => Some(service_account_key.as_str()),
484
            _ => None,
485
        };
486
        let principal = match given {
487
            Some(Credentials::Azure {
488
                tenant_id,
489
                client_id,
490
                client_secret,
491
            }) => Some((
492
                tenant_id.as_str(),
493
                client_id.as_str(),
494
                client_secret.as_str(),
495
            )),
496
            _ => None,
497
        };
2ddd4716 Andy McCall ↶ 498
        OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
499
        let runtime = tokio::runtime::Builder::new_multi_thread()
500
            .worker_threads(2)
501
            .thread_name("oxy-strata")
8d803563 Andy McCall ↶ 502
            .enable_all()
503
            .build()
504
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
505
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
506
            match parsed.scheme.as_str() {
507
                "s3" => (
7026bd7d Andy McCall ↶ 508
                    Arc::new(aws::open(&parsed, aws_keys)?),
8d803563 Andy McCall ↶ 509
                    parsed.path.clone(),
510
                    aws::HINT,
511
                ),
512
                "gs" => (
7026bd7d Andy McCall ↶ 513
                    Arc::new(gcp::open(&parsed, gcp_key)?),
8d803563 Andy McCall ↶ 514
                    parsed.path.clone(),
515
                    gcp::HINT,
516
                ),
517
                "az" => {
7026bd7d Andy McCall ↶ 518
                    let (store, prefix) = azure::open(&parsed, principal)?;
8d803563 Andy McCall ↶ 519
                    (Arc::new(store), prefix, azure::HINT)
520
                }
521
                other => {
522
                    return Err(Error::Backend(format!(
523
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
524
                    )))
525
                }
526
            };
527
        Ok(CloudStore {
528
            inner,
529
            prefix,
530
            name: parsed.plain(),
531
            hint,
c9369ad8 Andy McCall ↶ 532
            runtime: Some(runtime),
8b53b935 Andy McCall ↶ 533
            misses: Misses::default(),
8d803563 Andy McCall ↶ 534
        })
535
    }
536
c9369ad8 Andy McCall ↶ 537
    /// Run a request to completion. Called from a thread that is itself
538
    /// driving async tasks (a server's handler), where blocking on another
539
    /// runtime is not allowed, the request runs on a thread of its own.
540
    fn block<F>(&self, f: F) -> F::Output
541
    where
542
        F: Future + Send,
543
        F::Output: Send,
544
    {
545
        let runtime = self
546
            .runtime
547
            .as_ref()
548
            .expect("the runtime lives as long as the store");
2ddd4716 Andy McCall ↶ 549
        let started = std::time::Instant::now();
550
        let out = if tokio::runtime::Handle::try_current().is_ok() {
551
            std::thread::scope(|s| {
c9369ad8 Andy McCall ↶ 552
                s.spawn(|| runtime.block_on(f))
553
                    .join()
554
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
2ddd4716 Andy McCall ↶ 555
            })
556
        } else {
557
            runtime.block_on(f)
558
        };
559
        CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
560
        MICROS.fetch_add(
561
            started.elapsed().as_micros() as u64,
562
            std::sync::atomic::Ordering::Relaxed,
563
        );
564
        out
8d803563 Andy McCall ↶ 565
    }
566
567
    fn path(&self, key: &str) -> Result<ObjectPath> {
568
        check_key(key)?;
569
        let full = if self.prefix.is_empty() {
570
            key.to_string()
571
        } else {
572
            format!("{}/{key}", self.prefix)
573
        };
574
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
575
    }
576
577
    /// Our error for the provider's, with what to do next where it helps.
578
    fn map(&self, e: object_store::Error, key: &str) -> Error {
579
        use object_store::Error as E;
580
        match e {
581
            E::NotFound { .. } => Error::NotFound(key.to_string()),
582
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
583
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
584
            other => {
585
                let text = other.to_string();
586
                let lower = text.to_ascii_lowercase();
587
                let advice = if lower.contains("403")
588
                    || lower.contains("accessdenied")
589
                    || lower.contains("forbidden")
590
                    || lower.contains("authorizationpermissionmismatch")
591
                {
592
                    format!(
593
                        ": access denied; the credentials in use may not do this on {}: {}",
594
                        self.name, self.hint
595
                    )
596
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
597
                    format!(": no usable credentials: {}", self.hint)
598
                } else if lower.contains("nosuchbucket")
599
                    || lower.contains("bucketnotfound")
600
                    || lower.contains("containernotfound")
601
                {
602
                    format!(
603
                        ": {} does not exist, or the credentials cannot see it",
604
                        self.name
605
                    )
606
                } else if lower.contains("dns")
607
                    || lower.contains("connect")
608
                    || lower.contains("timed out")
609
                {
610
                    ": cannot reach the service; check the network, the endpoint and the region"
611
                        .to_string()
612
                } else {
613
                    String::new()
614
                };
615
                Error::Backend(format!("{}: {text}{advice}", self.name))
616
            }
617
        }
618
    }
619
620
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
621
        Version(format!(
622
            "{}\t{}",
623
            e_tag.unwrap_or_default(),
624
            version.unwrap_or_default()
625
        ))
626
    }
627
628
    fn update_version(v: &Version) -> UpdateVersion {
629
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
630
        UpdateVersion {
631
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
632
            version: (!version.is_empty()).then(|| version.to_string()),
633
        }
634
    }
635
}
636
637
impl Store for CloudStore {
638
    fn describe(&self) -> String {
639
        self.name.clone()
640
    }
641
642
    fn has(&self, key: &str) -> Result<bool> {
643
        let path = self.path(key)?;
8b53b935 Andy McCall ↶ 644
        if self.misses.known(key) {
645
            return Ok(false);
646
        }
8d803563 Andy McCall ↶ 647
        match self.block(self.inner.head(&path)) {
1e0fbb47 Andy McCall ↶ 648
            Ok(_) => {
649
                counted(Op::Has);
650
                Ok(true)
651
            }
652
            Err(object_store::Error::NotFound { .. }) => {
653
                counted(Op::HasMissing);
8b53b935 Andy McCall ↶ 654
                self.misses.missed(key);
1e0fbb47 Andy McCall ↶ 655
                Ok(false)
656
            }
8d803563 Andy McCall ↶ 657
            Err(e) => Err(self.map(e, key)),
658
        }
659
    }
660
661
    fn get(&self, key: &str) -> Result<Vec<u8>> {
662
        let path = self.path(key)?;
1e0fbb47 Andy McCall ↶ 663
        counted(Op::Get);
8d803563 Andy McCall ↶ 664
        self.block(async {
665
            let r = self.inner.get(&path).await?;
666
            r.bytes().await
667
        })
668
        .map(|b| b.to_vec())
669
        .map_err(|e| self.map(e, key))
670
    }
671
672
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
673
        let path = self.path(key)?;
674
        if range.start > range.end {
675
            return Err(Error::Backend(format!(
676
                "{key}: bytes {}..{} is not a range",
677
                range.start, range.end
678
            )));
679
        }
680
        if range.is_empty() {
681
            return Ok(Vec::new());
682
        }
683
        let want = (range.end - range.start) as usize;
1e0fbb47 Andy McCall ↶ 684
        counted(Op::Range);
8d803563 Andy McCall ↶ 685
        let bytes = self
686
            .block(self.inner.get_range(&path, range.clone()))
687
            .map_err(|e| self.map(e, key))?;
688
        if bytes.len() != want {
689
            return Err(Error::Backend(format!(
690
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
691
                range.start,
692
                range.end,
693
                bytes.len()
694
            )));
695
        }
696
        Ok(bytes.to_vec())
697
    }
698
699
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
700
        let path = self.path(key)?;
8b53b935 Andy McCall ↶ 701
        self.misses.written(key);
8d803563 Andy McCall ↶ 702
        let opts = PutOptions {
703
            mode: PutMode::Create,
704
            ..Default::default()
705
        };
1e0fbb47 Andy McCall ↶ 706
        counted(Op::Put);
8d803563 Andy McCall ↶ 707
        self.block(
708
            self.inner
709
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
710
        )
711
        .map(|_| ())
712
        .map_err(|e| self.map(e, key))
713
    }
714
715
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
716
        // Providers list by whole path segments; the asked prefix may end
717
        // mid-segment, so list its directory and filter here.
718
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
719
        let full_dir = match (self.prefix.as_str(), dir) {
720
            ("", "") => None,
721
            ("", d) => Some(d.to_string()),
722
            (p, "") => Some(p.to_string()),
723
            (p, d) => Some(format!("{p}/{d}")),
724
        };
725
        let full_dir = full_dir
726
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
727
            .transpose()?;
1e0fbb47 Andy McCall ↶ 728
        counted(Op::List);
8d803563 Andy McCall ↶ 729
        let metas: Vec<object_store::ObjectMeta> = self
730
            .block(self.inner.list(full_dir.as_ref()).try_collect())
731
            .map_err(|e| self.map(e, prefix))?;
732
        let strip = if self.prefix.is_empty() {
733
            String::new()
734
        } else {
735
            format!("{}/", self.prefix)
736
        };
737
        let mut out: Vec<Entry> = metas
738
            .into_iter()
739
            .filter_map(|m| {
740
                let loc = m.location.to_string();
741
                let key = loc.strip_prefix(&strip)?;
742
                key.starts_with(prefix).then(|| Entry {
743
                    key: key.to_string(),
744
                    size: m.size,
64818938 Andy McCall ↶ 745
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
8d803563 Andy McCall ↶ 746
                })
747
            })
748
            .collect();
749
        out.sort_by(|a, b| a.key.cmp(&b.key));
750
        Ok(out)
751
    }
752
753
    fn delete(&self, key: &str) -> Result<()> {
754
        let path = self.path(key)?;
1e0fbb47 Andy McCall ↶ 755
        counted(Op::Delete);
8d803563 Andy McCall ↶ 756
        match self.block(self.inner.delete(&path)) {
757
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
758
            Err(e) => Err(self.map(e, key)),
759
        }
760
    }
761
762
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
763
        let path = self.path(key)?;
1e0fbb47 Andy McCall ↶ 764
        counted(Op::ReadRef);
8d803563 Andy McCall ↶ 765
        let got = self.block(async {
766
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
767
            let meta = r.meta.clone();
768
            let bytes = r.bytes().await?;
769
            Ok::<_, object_store::Error>((bytes, meta))
770
        });
771
        match got {
772
            Ok((bytes, meta)) => Ok(Some((
773
                bytes.to_vec(),
774
                Self::version_of(meta.e_tag, meta.version),
775
            ))),
776
            Err(object_store::Error::NotFound { .. }) => Ok(None),
777
            Err(e) => Err(self.map(e, key)),
778
        }
779
    }
780
781
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
782
        let path = self.path(key)?;
8b53b935 Andy McCall ↶ 783
        self.misses.written(key);
8d803563 Andy McCall ↶ 784
        let mode = match expected {
785
            None => PutMode::Create,
786
            Some(v) => PutMode::Update(Self::update_version(v)),
787
        };
788
        let opts = PutOptions {
789
            mode,
790
            ..Default::default()
791
        };
1e0fbb47 Andy McCall ↶ 792
        counted(Op::WriteRef);
8d803563 Andy McCall ↶ 793
        let put = self
794
            .block(
795
                self.inner
796
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
797
            )
798
            .map_err(|e| match self.map(e, key) {
799
                // A record that exists when none was expected is a lost
800
                // race too, not a misuse of put_new.
801
                Error::Exists(k) => Error::Conflict(k),
802
                other => other,
803
            })?;
804
        Ok(Self::version_of(put.e_tag, put.version))
805
    }
806
}
807
808
#[cfg(test)]
809
mod tests {
8b53b935 Andy McCall ↶ 810
811
    /// A miss is remembered until the key is written, or for MISS_FOR.
812
    #[test]
813
    fn a_miss_is_remembered_until_written() {
814
        let m = super::Misses::default();
815
        assert!(!m.known("loose/ab/cd"));
816
        m.missed("loose/ab/cd");
817
        assert!(m.known("loose/ab/cd") && !m.known("loose/ab/ce"));
818
        m.written("loose/ab/cd");
819
        assert!(!m.known("loose/ab/cd"), "a write forgets the miss");
820
        m.missed("old");
821
        m.0.lock()
822
            .unwrap()
823
            .insert("old".into(), std::time::Instant::now() - super::MISS_FOR);
824
        assert!(!m.known("old"), "a miss expires");
825
    }
1e0fbb47 Andy McCall ↶ 826
827
    /// Every kind of request is reported, once and by its own name.
828
    #[test]
829
    fn stats_name_every_kind_of_request() {
830
        let names: Vec<&str> = super::stats().ops().map(|(n, _)| n).collect();
831
        assert_eq!(names.len(), super::Op::ALL.len());
832
        let mut unique = names.clone();
833
        unique.sort();
834
        unique.dedup();
835
        assert_eq!(unique.len(), names.len());
836
        assert!(names.contains(&"has_missing") && names.contains(&"range"));
837
    }
2ddd4716 Andy McCall ↶ 838
839
    /// One client per store and credentials (oxygenhub#234): opening the
840
    /// same again shares it, and only a new one counts as opened.
841
    #[test]
842
    fn a_store_opened_again_shares_its_client() {
843
        let creds = |k: &str| Credentials::Aws {
844
            access_key_id: k.into(),
845
            secret_access_key: "s".into(),
846
            session_token: None,
847
        };
848
        let url = "s3://oxy-shared-test/repos/a?region=eu-west-2";
849
        let before = super::stats().opens;
850
        let a = super::shared(url, Some(creds("one"))).unwrap();
851
        let b = super::shared(url, Some(creds("one"))).unwrap();
852
        assert!(std::sync::Arc::ptr_eq(&a, &b));
853
        let other_keys = super::shared(url, Some(creds("two"))).unwrap();
854
        assert!(
855
            !std::sync::Arc::ptr_eq(&a, &other_keys),
856
            "other credentials, another client"
857
        );
858
        assert!(
859
            super::stats().opens - before >= 2,
860
            "two clients opened (other tests may open more)"
861
        );
862
    }
863
c9369ad8 Andy McCall ↶ 864
    #[tokio::test]
865
    async fn a_store_can_be_used_from_inside_a_runtime() {
866
        // As a server's handler would: blocking on the store's own runtime
867
        // from here must neither panic nor deadlock, and nor must dropping it.
868
        let store = CloudStore::open(
869
            "s3://oxy-no-such-bucket/p?region=eu-west-2&endpoint=http://127.0.0.1:9",
870
        )
871
        .unwrap();
872
        assert_eq!(store.block(async { 7 }), 7);
873
        drop(store);
874
    }
875
7026bd7d Andy McCall ↶ 876
    #[test]
877
    fn credentials_round_trip_and_never_show_their_secret() {
878
        let aws = Credentials::Aws {
879
            access_key_id: "AKIDEXAMPLE".into(),
880
            secret_access_key: "wJalrXUtnFEMI".into(),
881
            session_token: None,
882
        };
883
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
884
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
885
        let az = Credentials::Azure {
886
            tenant_id: "t".into(),
887
            client_id: "c".into(),
888
            client_secret: "s3cr3t".into(),
889
        };
890
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
891
        assert!(!format!("{az:?}").contains("s3cr3t"));
892
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
893
        let gcp = Credentials::Gcp {
894
            service_account_key: key.into(),
895
        };
896
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
897
        assert!(!format!("{gcp:?}").contains("private_key"));
898
        for (bad, why) in [
899
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
900
            (
901
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
902
                "not a service account key",
903
            ),
904
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
905
            ("nope", "not JSON"),
906
        ] {
907
            let e = Credentials::from_json(bad).unwrap_err().to_string();
908
            assert!(e.contains(why), "{bad}: {e}");
909
        }
910
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
911
            .err()
912
            .unwrap()
913
            .to_string();
914
        assert!(e.contains("are for s3:// stores"), "{e}");
915
    }
916
8d803563 Andy McCall ↶ 917
    use super::*;
918
919
    #[test]
920
    fn urls_parse_with_their_parameters() {
921
        let u =
922
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
923
        assert_eq!(u.scheme, "s3");
924
        assert_eq!(u.host, "my-bucket");
925
        assert_eq!(u.path, "repos/oxygen");
926
        assert_eq!(u.param("profile"), Some("work"));
927
        assert_eq!(u.param("region"), Some("eu-west-2"));
928
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
929
        let u = StoreUrl::parse("gs://bucket").unwrap();
930
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
931
        assert_eq!(u.plain(), "gs://bucket");
932
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
933
            .unwrap();
934
        assert_eq!(u.path, "container/a/b");
935
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
936
        assert!(StoreUrl::parse("s3://").is_err());
937
        assert!(StoreUrl::parse("nonsense").is_err());
938
    }
939
940
    #[test]
941
    fn versions_carry_both_an_etag_and_a_generation() {
942
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
943
        let u = CloudStore::update_version(&v);
944
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
945
        assert_eq!(u.version.as_deref(), Some("17"));
946
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
947
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
948
    }
949
}