OXYGENHUB

Coverage / crates/hub/src/storage.rs 52.5%

156 of 297 lines run, 33 of 117 functions · run #500 on main · run missed

1
//! Repositories kept in their owner's bucket (hub spec §4, oxygenhub#96,
2
//! oxygen#68). The repository's objects and operation log live in an S3,
3
//! Google Cloud Storage or Azure bucket the owner chose; the hub keeps a
4
//! bare repository directory with a cache in front (`storage.url` in its
5
//! config) and the credentials, sealed under `keys/secrets.key`. Stores
6
//! are opened by URL, and the credentials are handed to them through
7
//! oxy-strata's lookup, so nothing from the hub host's own environment or
8
//! cloud profiles is ever used for a repository.
9
10
use crate::db::{now_ms, Pool};
11
use crate::Shared;
12
use oxy_strata::Credentials;
13
use std::collections::HashMap;
14
use std::sync::RwLock;
15
16
#[derive(Clone, Debug, sqlx::FromRow)]
17
pub struct Storage {
18
    pub repo_id: i64,
19
    pub url: String,
20
    /// Sealed; `App.secret_key` opens it.
21
    pub credentials: Vec<u8>,
22
    pub created_ms: i64,
23
    pub checked_ms: Option<i64>,
24
    pub check_result: String,
25
}
26
27
/// What the stores opened by URL get: each repository's credentials by
28
/// its store URL, and the URL by repository id for pages.
29
#[derive(Default)]
30
struct Known {
31
    credentials: HashMap<String, Credentials>,
32
    /// By the repository's directory, unique even between hubs in one process.
33
    urls: HashMap<std::path::PathBuf, String>,
34
}
35
36
static KNOWN: RwLock<Option<Known>> = RwLock::new(None);
37
380×
fn lookup(url: &str) -> Option<Credentials> {
390×
    KNOWN.read().ok()?.as_ref()?.credentials.get(url).cloned()
400×
}
41
420×
fn remember(dir: std::path::PathBuf, url: &str, credentials: Credentials) {
430×
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
440×
    let known = known.get_or_insert_with(Known::default);
450×
    known.credentials.insert(url.to_string(), credentials);
460×
    known.urls.insert(dir, url.to_string());
470×
}
48
491×
fn forget(dir: &std::path::Path) {
501×
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
511×
    if let Some(k) = known.as_mut() {
520×
        if let Some(url) = k.urls.remove(dir) {
530×
            k.credentials.remove(&url);
540×
        }
551×
    }
561×
}
57
58
/// Where a repository's data lives, when it is not on this hub's disk.
59
/// What the repository header shows of a store (#123): which provider,
60
/// and, for the repository's admins, where in it.
61
#[derive(Clone, Debug, PartialEq)]
62
pub struct StoreBadge {
63
    /// `gcs`, `s3` or `azure`: picks the icon.
64
    pub kind: &'static str,
65
    pub provider: &'static str,
66
    /// The bucket, or for Azure the storage account and container.
67
    pub place: String,
68
    pub prefix: String,
69
    pub region: Option<String>,
70
    /// The URL as stored, query and all.
71
    pub url: String,
72
}
73
74
impl StoreBadge {
75
    /// The parts of a store URL; `None` for a scheme the hub does not
76
    /// keep repositories on.
776×
    pub fn of(url: &str) -> Option<StoreBadge> {
786×
        let (scheme, rest) = url.split_once("://")?;
796×
        let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
806×
        let region = query.split('&').find_map(|kv| kv.strip_prefix("region=")).filter(|r| !r.is_empty()).map(str::to_string);
816×
        let mut parts = path.trim_matches('/').splitn(if scheme == "az" { 3 } else { 2 }, '/');
826×
        let (kind, provider, place) = match scheme {
836×
            "gs" => ("gcs", "Google Cloud Storage", parts.next()?.to_string()),
845×
            "s3" => ("s3", "Amazon S3", parts.next()?.to_string()),
853×
            "az" => ("azure", "Azure Blob Storage", format!("{} / {}", parts.next()?, parts.next()?)),
861×
            _ => return None,
87
        };
884×
        Some(StoreBadge { kind, provider, place, prefix: parts.next().unwrap_or("").to_string(), region, url: url.to_string() })
896×
    }
90
}
91
92524×
pub fn location(app: &Shared, owner: &str, name: &str) -> Option<String> {
93524×
    let dir = app.repos.dir(&crate::repos::RepoName::new(owner, name)?);
94524×
    KNOWN.read().ok()?.as_ref()?.urls.get(&dir).cloned()
95524×
}
96
97
/// The directory of the repository with row `repo_id`.
980×
async fn dir_of(app: &Shared, repo_id: i64) -> Option<std::path::PathBuf> {
990×
    let row = crate::db::repos::get_by_id(&app.db, repo_id).await.ok()??;
1000×
    Some(app.repos.dir(&crate::repos::RepoName::new(&row.owner, &row.name)?))
1010×
}
102
103
/// Hand the cloud stores to oxy-core, and every repository's credentials
104
/// to them. At start.
105194×
pub async fn load(app: &Shared) -> sqlx::Result<()> {
106194×
    oxy_strata::register();
107194×
    oxy_strata::set_credentials_lookup(lookup);
108194×
    for s in all(&app.db).await? {
1090×
        match app.secret_key.open(&s.credentials).map(|j| Credentials::from_json(&j)) {
1100×
            Some(Ok(c)) => match dir_of(app, s.repo_id).await {
1110×
                Some(dir) => remember(dir, &s.url, c),
1120×
                None => tracing::warn!("storage: repository {} has a store but no row", s.repo_id),
113
            },
1140×
            _ => tracing::warn!("storage: the credentials of repository {} do not open under keys/secrets.key", s.repo_id),
115
        }
116
    }
117194×
    Ok(())
118194×
}
119
120194×
pub async fn all(pool: &Pool) -> sqlx::Result<Vec<Storage>> {
121194×
    sqlx::query_as("SELECT * FROM repo_storage").fetch_all(pool).await
122194×
}
123
124141×
pub async fn of(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<Storage>> {
125141×
    sqlx::query_as("SELECT * FROM repo_storage WHERE repo_id = $1").bind(repo_id).fetch_optional(pool).await
126141×
}
127
128
/// Check a store URL a user gave: a bucket on S3, Google Cloud Storage or
129
/// Azure, with no setting but `region`. A profile, a key file or an
130
/// endpoint would make the hub use its own host's credentials or reach
131
/// addresses on its network, and `dir://` its own disk.
13217×
pub fn check_url(url: &str) -> Result<oxy_strata::StoreUrl, String> {
13317×
    let scheme = url.trim().split_once("://").map(|(s, _)| s).unwrap_or("");
13417×
    if !matches!(scheme, "s3" | "gs" | "az") {
1353×
        return Err("a store is s3://BUCKET/PREFIX, gs://BUCKET/PREFIX or az://ACCOUNT/CONTAINER/PREFIX".into());
13614×
    }
13714×
    let parsed = oxy_strata::StoreUrl::parse(url.trim()).map_err(|e| e.to_string())?;
13814×
    if parsed.scheme == "az" && parsed.path.is_empty() {
1392×
        return Err("an Azure store names the account and the container: az://ACCOUNT/CONTAINER/PREFIX".into());
14012×
    }
14112×
    if let Some(k) = parsed.params.keys().find(|k| k.as_str() != "region") {
1426×
        return Err(format!("?{k}= is not allowed here: the only setting a hub accepts is ?region="));
1436×
    }
1446×
    Ok(parsed)
14517×
}
146
147
/// The credentials a form gave for a store of `scheme`.
1481×
pub fn credentials_from(
1491×
    scheme: &str,
1501×
    access_key_id: &str,
1511×
    secret_access_key: &str,
1521×
    service_account_key: &str,
1531×
    tenant_id: &str,
1541×
    client_id: &str,
1551×
    client_secret: &str,
1561×
) -> Result<Credentials, String> {
1571×
    let json = match scheme {
1581×
        "s3" => serde_json::json!({ "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key }),
1590×
        "gs" => serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key }),
1600×
        "az" => serde_json::json!({ "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret }),
1610×
        _ => return Err("unknown kind of store".into()),
162
    };
1631×
    Credentials::from_json(&json.to_string()).map_err(|e| e.to_string())
1641×
}
165
166
/// Run the conformance check against a store under a probe prefix of its
167
/// own, which it leaves empty: what passed, or what failed. With
168
/// `must_be_empty` the store must hold nothing yet (a new repository's).
1690×
pub async fn probe(url: &str, credentials: &Credentials, must_be_empty: bool) -> Result<String, String> {
1700×
    let (url, credentials) = (url.to_string(), credentials.clone());
1710×
    tokio::task::spawn_blocking(move || {
172
        use oxy_core::strata::Store as _;
1730×
        let store = oxy_strata::CloudStore::open_with(&url, Some(&credentials)).map_err(|e| e.to_string())?;
1740×
        if must_be_empty {
1750×
            let found = store.list("").map_err(|e| e.to_string())?;
1760×
            if !found.is_empty() {
1770×
                return Err(format!(
1780×
                    "{} already holds {} object(s): a new repository needs an empty bucket or prefix",
1790×
                    store.describe(),
1800×
                    found.len()
1810×
                ));
1820×
            }
1830×
        }
1840×
        let mut nonce = [0u8; 6];
1850×
        rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce);
1860×
        let prefix = format!("oxy-probe-{}", hex::encode(nonce));
1870×
        let passed = oxy_core::strata::conformance(&store, &prefix).map_err(|e| {
1880×
            for e in store.list(&prefix).unwrap_or_default() {
1890×
                let _ = store.delete(&e.key);
1900×
            }
1910×
            e.to_string()
1920×
        })?;
1930×
        Ok(format!("{} checks passed", passed.len()))
1940×
    })
1950×
    .await
1960×
    .map_err(|e| e.to_string())?
1970×
}
198
199
/// Create repository `name` (row `repo_id`, already in the database) in
200
/// the store at `url`: checked first, then the credentials sealed and
201
/// kept, then the bare repository made over the store.
2020×
pub async fn create(app: &Shared, name: &crate::repos::RepoName, repo_id: i64, url: &str, credentials: Credentials) -> Result<(), String> {
2030×
    let result = probe(url, &credentials, true).await?;
2040×
    let sealed = app.secret_key.seal(&credentials.to_json());
2050×
    let dir = app.repos.dir(name);
2060×
    remember(dir.clone(), url, credentials);
2070×
    let made = {
2080×
        let (app, name, url) = (app.clone(), name.clone(), url.to_string());
2090×
        tokio::task::spawn_blocking(move || app.repos.create_on(&name, &url).map(drop).map_err(|e| e.to_string()))
2100×
            .await
2110×
            .map_err(|e| e.to_string())
2120×
            .and_then(|r| r)
213
    };
2140×
    if let Err(e) = made {
2150×
        forget(&dir);
2160×
        return Err(format!("the repository could not be made in {url}: {e}"));
2170×
    }
2180×
    let now = now_ms();
2190×
    sqlx::query(
2200×
        "INSERT INTO repo_storage (repo_id, url, credentials, created_ms, checked_ms, check_result) VALUES ($1, $2, $3, $4, $5, $6)",
2210×
    )
2220×
    .bind(repo_id)
2230×
    .bind(url)
2240×
    .bind(sealed)
2250×
    .bind(now)
2260×
    .bind(now)
2270×
    .bind(&result)
2280×
    .execute(&app.db)
2290×
    .await
2300×
    .map_err(|e| e.to_string())?;
2310×
    Ok(())
2320×
}
233
234
/// Check a repository's store again and record the result.
2350×
pub async fn check(app: &Shared, s: &Storage) -> Result<String, String> {
2360×
    let json = app.secret_key.open(&s.credentials).ok_or("the credentials do not open under this hub's key")?;
2370×
    let credentials = Credentials::from_json(&json).map_err(|e| e.to_string())?;
2380×
    let result = probe(&s.url, &credentials, false).await;
2390×
    let text = match &result {
2400×
        Ok(r) => r.clone(),
2410×
        Err(e) => format!("failed: {e}"),
242
    };
2430×
    sqlx::query("UPDATE repo_storage SET checked_ms = $1, check_result = $2 WHERE repo_id = $3")
2440×
        .bind(now_ms())
2450×
        .bind(&text)
2460×
        .bind(s.repo_id)
2470×
        .execute(&app.db)
2480×
        .await
2490×
        .map_err(|e| e.to_string())?;
2500×
    result
2510×
}
252
253
/// Replace a repository's credentials (a rotated key), once they pass the
254
/// check against its store.
2550×
pub async fn set_credentials(app: &Shared, s: &Storage, credentials: Credentials) -> Result<String, String> {
2560×
    let result = probe(&s.url, &credentials, false).await?;
2570×
    sqlx::query("UPDATE repo_storage SET credentials = $1, checked_ms = $2, check_result = $3 WHERE repo_id = $4")
2580×
        .bind(app.secret_key.seal(&credentials.to_json()))
2590×
        .bind(now_ms())
2600×
        .bind(&result)
2610×
        .bind(s.repo_id)
2620×
        .execute(&app.db)
2630×
        .await
2640×
        .map_err(|e| e.to_string())?;
2650×
    if let Some(dir) = dir_of(app, s.repo_id).await {
2660×
        remember(dir, &s.url, credentials);
2670×
    }
2680×
    Ok(result)
2690×
}
270
271
/// A repository is gone (its store row with it): forget its credentials.
2721×
pub fn removed(app: &Shared, name: &crate::repos::RepoName) {
2731×
    forget(&app.repos.dir(name));
2741×
}
275
276
/// A repository moved to another name (a transfer): its store goes with it.
2771×
pub fn renamed(app: &Shared, from: &crate::repos::RepoName, to: &crate::repos::RepoName) {
2781×
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
2791×
    if let Some(k) = known.as_mut() {
2800×
        if let Some(url) = k.urls.remove(&app.repos.dir(from)) {
2810×
            k.urls.insert(app.repos.dir(to), url);
2820×
        }
2831×
    }
2841×
}
285
286
/// An organisation's storage policy: every new repository in a bucket,
287
/// and only in stores under these URL prefixes when there are any. The
288
/// buckets an organisation lists are its own, so where they are (the
289
/// region, the provider, the country) is where its data is.
290
#[derive(Clone, Debug, Default, PartialEq, Eq)]
291
pub struct Policy {
292
    pub require_bucket: bool,
293
    pub allowed: Vec<String>,
294
}
295
296
impl Policy {
297
    /// Whether a new repository may be kept at `store` (`None`: this
298
    /// hub's disk); otherwise what to tell its creator.
2999×
    pub fn permits(&self, org: &str, store: Option<&str>) -> Result<(), String> {
3006×
        match store {
3012×
            None if self.require_bucket => Err(format!(
302
                "{org} keeps every new repository in a bucket: choose “my own bucket” under Storage{}",
3032×
                if self.allowed.is_empty() { String::new() } else { format!(" ({})", self.allowed.join(", ")) }
304
            )),
3055×
            Some(url) if !self.allowed.is_empty() && !self.allowed.iter().any(|p| url.trim().starts_with(p.as_str())) => {
3062×
                Err(format!("{org} allows only these stores: {}", self.allowed.join(", ")))
307
            }
3085×
            _ => Ok(()),
309
        }
3109×
    }
311
}
312
31336×
pub async fn policy(pool: &Pool, org_id: i64) -> sqlx::Result<Policy> {
31436×
    let row: Option<(bool, String)> = sqlx::query_as("SELECT require_bucket, allowed FROM org_storage_policy WHERE org_id = $1")
31536×
        .bind(org_id)
31636×
        .fetch_optional(pool)
31736×
        .await?;
31836×
    Ok(row
31936×
        .map(|(r, a)| Policy { require_bucket: r, allowed: a.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect() })
32036×
        .unwrap_or_default())
32136×
}
322
323
/// Set a policy from what a form gave: the allowed prefixes one per line
324
/// (or comma-separated), each a store URL of a bucket.
3252×
pub async fn set_policy(pool: &Pool, org_id: i64, require_bucket: bool, allowed: &str) -> Result<Policy, String> {
3262×
    let mut prefixes = Vec::new();
3273×
    for p in allowed.split([',', '\n']).map(str::trim).filter(|p| !p.is_empty()) {
3283×
        if p.contains('?') {
3291×
            return Err(format!("{p}: a prefix names a bucket and a path, with no settings"));
3302×
        }
3312×
        check_url(p).map_err(|e| format!("{p}: {e}"))?;
3322×
        prefixes.push(p.to_string());
333
    }
3341×
    sqlx::query(
3351×
        "INSERT INTO org_storage_policy (org_id, require_bucket, allowed) VALUES ($1, $2, $3) \
3361×
         ON CONFLICT (org_id) DO UPDATE SET require_bucket = excluded.require_bucket, allowed = excluded.allowed",
3371×
    )
3381×
    .bind(org_id)
3391×
    .bind(require_bucket)
3401×
    .bind(prefixes.join("\n"))
3411×
    .execute(pool)
3421×
    .await
3431×
    .map_err(|e| e.to_string())?;
3441×
    Ok(Policy { require_bucket, allowed: prefixes })
3452×
}
346
347
#[cfg(test)]
348
mod tests {
349
    use super::*;
350
351
    #[test]
3521×
    fn store_urls_a_hub_accepts() {
3531×
        assert!(check_url("s3://bucket/repos/x?region=eu-west-2").is_ok());
3541×
        assert!(check_url("gs://bucket/x").is_ok());
3551×
        assert!(check_url("az://account/container/x").is_ok());
3566×
        for (bad, why) in [
3571×
            ("s3://b/x?profile=prod", "?profile="),
3581×
            ("s3://b/x?region=a&endpoint=http://10.0.0.1", "?endpoint="),
3591×
            ("gs://b/x?credentials=/etc/key.json", "?credentials="),
3601×
            ("dir:///srv", "a store is"),
3611×
            ("file:///srv", "a store is"),
3621×
            ("az://account", "the container"),
3631×
        ] {
3646×
            let e = check_url(bad).unwrap_err();
3656×
            assert!(e.contains(why), "{bad}: {e}");
366
        }
3671×
    }
368
369
    #[test]
3701×
    fn a_policy_says_what_it_allows() {
3711×
        let open = Policy::default();
3721×
        assert!(open.permits("acme", None).is_ok());
3731×
        let strict = Policy { require_bucket: true, allowed: vec!["s3://acme-london/".into(), "gs://acme-eu/".into()] };
3741×
        let e = strict.permits("acme", None).unwrap_err();
3751×
        assert!(e.contains("acme keeps every new repository in a bucket") && e.contains("s3://acme-london/"), "{e}");
3761×
        assert!(strict.permits("acme", Some("s3://acme-london/repos/x?region=eu-west-2")).is_ok());
3771×
        let e = strict.permits("acme", Some("s3://acme-virginia/x")).unwrap_err();
3781×
        assert!(e.contains("allows only these stores"), "{e}");
3791×
    }
380
}
381
382
#[cfg(test)]
383
mod badge_tests {
384
    use super::StoreBadge;
385
386
    #[test]
3871×
    fn a_store_url_names_its_provider_place_prefix_and_region() {
3881×
        let g = StoreBadge::of("gs://doomsdayone-oxy-repos/repos/tetris").unwrap();
3891×
        assert_eq!(
3901×
            (g.kind, g.provider, g.place.as_str(), g.prefix.as_str(), g.region),
391
            ("gcs", "Google Cloud Storage", "doomsdayone-oxy-repos", "repos/tetris", None)
392
        );
3931×
        let s = StoreBadge::of("s3://my-bucket/repos/name?region=eu-west-2").unwrap();
3941×
        assert_eq!(
3951×
            (s.kind, s.place.as_str(), s.prefix.as_str(), s.region.as_deref()),
396
            ("s3", "my-bucket", "repos/name", Some("eu-west-2"))
397
        );
3981×
        let a = StoreBadge::of("az://account/container/repos/x").unwrap();
3991×
        assert_eq!((a.kind, a.place.as_str(), a.prefix.as_str()), ("azure", "account / container", "repos/x"));
4001×
        assert_eq!(StoreBadge::of("s3://bucket").unwrap().prefix, "");
4011×
        assert!(StoreBadge::of("dir:///tmp/x").is_none());
4021×
        assert!(StoreBadge::of("az://only-account").is_none());
4031×
    }
404
}