| 8d803563 Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 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 |
|
| 32 |
|
| 33 |
pub fn register() { |
| 34 |
|
| 35 |
|
| 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 |
|
| 52 |
#[derive(Clone, Debug, PartialEq, Eq)] |
| 53 |
pub struct StoreUrl { |
| 54 |
pub scheme: String, |
| 55 |
|
| 56 |
pub host: String, |
| 57 |
|
| 58 |
|
| 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 |
|
| 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 |
|
| 124 |
|
| 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 |
|
| 132 |
turn: Mutex<()>, |
| 133 |
} |
| 134 |
|
| 135 |
impl CloudStore { |
| 136 |
|
| 137 |
|
| 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 |
|
| 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 |
|
| 317 |
|
| 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 |
|
| 394 |
|
| 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®ion=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 |
} |