OXYGENHUB

A repository can live in its owner's bucket 1f78655a0466

New repository → Storage: this server's disk, or an S3, Google Cloud
Storage or Azure bucket with credentials for it. The URL may carry only
?region= (no profile, key file, endpoint or dir://, which would use the
hub's own credentials, network or disk); the bucket must be empty and
pass the store conformance check before anything is made. The hub keeps
a bare repository over the store with a cache in front, and the
credentials sealed under keys/secrets.key in repo_storage (migration
0044), handed to the cloud stores by URL at start. The header shows a
bucket tag (the URL to admins only); Settings → Storage checks the bucket
again and replaces the credentials once new ones pass. Forking such a
repository is refused for now. Live test against a real S3 bucket: a
push lands in it and the check passes. refs #96
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 01:37 UTC
Signature
✔ signed by Andy McCall
Hash
1f78655a04662dea5b241598ec5ef9e1dd480a25c7fe90168712471ef3ed7bb7
Depends on
07b1a5bbf481 18e381ea88c2 19bb31ff1f5c 25091e5c3bba 2a83f5303458 380996db84e5 4ac01a732552 4d2e44d79b03 64caed18236a 66fd9d6b1452 749d9a5fcd9f 7974adb0be66 89e89836cae7 9008bf6c2b8c 93be9ce4653e a160b98fb922 b5668108487f b64e2537d702 d0fbea2eb46e d23464c0ac60 e71846aa07b9 ea85398a51bc
Depended on by
1062a0f8e985 40a43921ef42 b7e1be264c0a 3d086bc3d69b 12325e1b1020 15d3f6f7871b dd0788b756f2 69e4f27c5633 a549ddd5941f c7067c7bda56 9c04c779f032 33d98147511b 0ee5c3fb32eb 965b950f9b96 559c39204234 4cc40ccc6034 c8684d44d549 3b1bc27fe805 5d90e0e045e2 d3b048b27277 e71583501b1d a46d15e72101 8aafd2023dec 7b26e6118bc0 4bae285c6ed8 336c953a020f 7edaff01d506 554ccacfc123 8ddebaa7e856
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
52 atoms, 3 tree operations, 16 files

Browse the tree at this change

added crates/hub/migrations/0044_repo_storage.sql +11 −0
1 +
-- Repositories kept in their owner's bucket (oxygenhub#96, oxygen#68):
2 +
-- the store's URL, the credentials sealed under keys/secrets.key, and the
3 +
-- last check of the store.
4 +
CREATE TABLE repo_storage (
5 +
    repo_id      INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
6 +
    url          TEXT NOT NULL,
7 +
    credentials  BLOB NOT NULL,
8 +
    created_ms   INTEGER NOT NULL,
9 +
    checked_ms   INTEGER,
10 +
    check_result TEXT NOT NULL DEFAULT ''
11 +
);
added crates/hub/src/storage.rs +249 −0
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 +
38 +
fn lookup(url: &str) -> Option<Credentials> {
39 +
    KNOWN.read().ok()?.as_ref()?.credentials.get(url).cloned()
40 +
}
41 +
42 +
fn remember(dir: std::path::PathBuf, url: &str, credentials: Credentials) {
43 +
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
44 +
    let known = known.get_or_insert_with(Known::default);
45 +
    known.credentials.insert(url.to_string(), credentials);
46 +
    known.urls.insert(dir, url.to_string());
47 +
}
48 +
49 +
fn forget(dir: &std::path::Path) {
50 +
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
51 +
    if let Some(k) = known.as_mut() {
52 +
        if let Some(url) = k.urls.remove(dir) {
53 +
            k.credentials.remove(&url);
54 +
        }
55 +
    }
56 +
}
57 +
58 +
/// Where a repository's data lives, when it is not on this hub's disk.
59 +
pub fn location(app: &Shared, owner: &str, name: &str) -> Option<String> {
60 +
    let dir = app.repos.dir(&crate::repos::RepoName::new(owner, name)?);
61 +
    KNOWN.read().ok()?.as_ref()?.urls.get(&dir).cloned()
62 +
}
63 +
64 +
/// The directory of the repository with row `repo_id`.
65 +
async fn dir_of(app: &Shared, repo_id: i64) -> Option<std::path::PathBuf> {
66 +
    let row = crate::db::repos::get_by_id(&app.db, repo_id).await.ok()??;
67 +
    Some(app.repos.dir(&crate::repos::RepoName::new(&row.owner, &row.name)?))
68 +
}
69 +
70 +
/// Hand the cloud stores to oxy-core, and every repository's credentials
71 +
/// to them. At start.
72 +
pub async fn load(app: &Shared) -> sqlx::Result<()> {
73 +
    oxy_strata::register();
74 +
    oxy_strata::set_credentials_lookup(lookup);
75 +
    for s in all(&app.db).await? {
76 +
        match app.secret_key.open(&s.credentials).map(|j| Credentials::from_json(&j)) {
77 +
            Some(Ok(c)) => match dir_of(app, s.repo_id).await {
78 +
                Some(dir) => remember(dir, &s.url, c),
79 +
                None => tracing::warn!("storage: repository {} has a store but no row", s.repo_id),
80 +
            },
81 +
            _ => tracing::warn!("storage: the credentials of repository {} do not open under keys/secrets.key", s.repo_id),
82 +
        }
83 +
    }
84 +
    Ok(())
85 +
}
86 +
87 +
pub async fn all(pool: &Pool) -> sqlx::Result<Vec<Storage>> {
88 +
    sqlx::query_as("SELECT * FROM repo_storage").fetch_all(pool).await
89 +
}
90 +
91 +
pub async fn of(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<Storage>> {
92 +
    sqlx::query_as("SELECT * FROM repo_storage WHERE repo_id = ?").bind(repo_id).fetch_optional(pool).await
93 +
}
94 +
95 +
/// Check a store URL a user gave: a bucket on S3, Google Cloud Storage or
96 +
/// Azure, with no setting but `region`. A profile, a key file or an
97 +
/// endpoint would make the hub use its own host's credentials or reach
98 +
/// addresses on its network, and `dir://` its own disk.
99 +
pub fn check_url(url: &str) -> Result<oxy_strata::StoreUrl, String> {
100 +
    let scheme = url.trim().split_once("://").map(|(s, _)| s).unwrap_or("");
101 +
    if !matches!(scheme, "s3" | "gs" | "az") {
102 +
        return Err("a store is s3://BUCKET/PREFIX, gs://BUCKET/PREFIX or az://ACCOUNT/CONTAINER/PREFIX".into());
103 +
    }
104 +
    let parsed = oxy_strata::StoreUrl::parse(url.trim()).map_err(|e| e.to_string())?;
105 +
    if parsed.scheme == "az" && parsed.path.is_empty() {
106 +
        return Err("an Azure store names the account and the container: az://ACCOUNT/CONTAINER/PREFIX".into());
107 +
    }
108 +
    if let Some(k) = parsed.params.keys().find(|k| k.as_str() != "region") {
109 +
        return Err(format!("?{k}= is not allowed here: the only setting a hub accepts is ?region="));
110 +
    }
111 +
    Ok(parsed)
112 +
}
113 +
114 +
/// The credentials a form gave for a store of `scheme`.
115 +
pub fn credentials_from(
116 +
    scheme: &str,
117 +
    access_key_id: &str,
118 +
    secret_access_key: &str,
119 +
    service_account_key: &str,
120 +
    tenant_id: &str,
121 +
    client_id: &str,
122 +
    client_secret: &str,
123 +
) -> Result<Credentials, String> {
124 +
    let json = match scheme {
125 +
        "s3" => serde_json::json!({ "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key }),
126 +
        "gs" => serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key }),
127 +
        "az" => serde_json::json!({ "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret }),
128 +
        _ => return Err("unknown kind of store".into()),
129 +
    };
130 +
    Credentials::from_json(&json.to_string()).map_err(|e| e.to_string())
131 +
}
132 +
133 +
/// Run the conformance check against a store under a probe prefix of its
134 +
/// own, which it leaves empty: what passed, or what failed. With
135 +
/// `must_be_empty` the store must hold nothing yet (a new repository's).
136 +
pub async fn probe(url: &str, credentials: &Credentials, must_be_empty: bool) -> Result<String, String> {
137 +
    let (url, credentials) = (url.to_string(), credentials.clone());
138 +
    tokio::task::spawn_blocking(move || {
139 +
        use oxy_core::strata::Store as _;
140 +
        let store = oxy_strata::CloudStore::open_with(&url, Some(&credentials)).map_err(|e| e.to_string())?;
141 +
        if must_be_empty {
142 +
            let found = store.list("").map_err(|e| e.to_string())?;
143 +
            if !found.is_empty() {
144 +
                return Err(format!(
145 +
                    "{} already holds {} object(s): a new repository needs an empty bucket or prefix",
146 +
                    store.describe(),
147 +
                    found.len()
148 +
                ));
149 +
            }
150 +
        }
151 +
        let mut nonce = [0u8; 6];
152 +
        rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce);
153 +
        let prefix = format!("oxy-probe-{}", hex::encode(nonce));
154 +
        let passed = oxy_core::strata::conformance(&store, &prefix).map_err(|e| {
155 +
            for e in store.list(&prefix).unwrap_or_default() {
156 +
                let _ = store.delete(&e.key);
157 +
            }
158 +
            e.to_string()
159 +
        })?;
160 +
        Ok(format!("{} checks passed", passed.len()))
161 +
    })
162 +
    .await
163 +
    .map_err(|e| e.to_string())?
164 +
}
165 +
166 +
/// Create repository `name` (row `repo_id`, already in the database) in
167 +
/// the store at `url`: checked first, then the credentials sealed and
168 +
/// kept, then the bare repository made over the store.
169 +
pub async fn create(app: &Shared, name: &crate::repos::RepoName, repo_id: i64, url: &str, credentials: Credentials) -> Result<(), String> {
170 +
    let result = probe(url, &credentials, true).await?;
171 +
    let sealed = app.secret_key.seal(&credentials.to_json());
172 +
    let dir = app.repos.dir(name);
173 +
    remember(dir.clone(), url, credentials);
174 +
    let made = {
175 +
        let (app, name, url) = (app.clone(), name.clone(), url.to_string());
176 +
        tokio::task::spawn_blocking(move || app.repos.create_on(&name, &url).map(drop).map_err(|e| e.to_string()))
177 +
            .await
178 +
            .map_err(|e| e.to_string())
179 +
            .and_then(|r| r)
180 +
    };
181 +
    if let Err(e) = made {
182 +
        forget(&dir);
183 +
        return Err(format!("the repository could not be made in {url}: {e}"));
184 +
    }
185 +
    let now = now_ms();
186 +
    sqlx::query("INSERT INTO repo_storage (repo_id, url, credentials, created_ms, checked_ms, check_result) VALUES (?, ?, ?, ?, ?, ?)")
187 +
        .bind(repo_id)
188 +
        .bind(url)
189 +
        .bind(sealed)
190 +
        .bind(now)
191 +
        .bind(now)
192 +
        .bind(&result)
193 +
        .execute(&app.db)
194 +
        .await
195 +
        .map_err(|e| e.to_string())?;
196 +
    Ok(())
197 +
}
198 +
199 +
/// Check a repository's store again and record the result.
200 +
pub async fn check(app: &Shared, s: &Storage) -> Result<String, String> {
201 +
    let json = app.secret_key.open(&s.credentials).ok_or("the credentials do not open under this hub's key")?;
202 +
    let credentials = Credentials::from_json(&json).map_err(|e| e.to_string())?;
203 +
    let result = probe(&s.url, &credentials, false).await;
204 +
    let text = match &result {
205 +
        Ok(r) => r.clone(),
206 +
        Err(e) => format!("failed: {e}"),
207 +
    };
208 +
    sqlx::query("UPDATE repo_storage SET checked_ms = ?, check_result = ? WHERE repo_id = ?")
209 +
        .bind(now_ms())
210 +
        .bind(&text)
211 +
        .bind(s.repo_id)
212 +
        .execute(&app.db)
213 +
        .await
214 +
        .map_err(|e| e.to_string())?;
215 +
    result
216 +
}
217 +
218 +
/// Replace a repository's credentials (a rotated key), once they pass the
219 +
/// check against its store.
220 +
pub async fn set_credentials(app: &Shared, s: &Storage, credentials: Credentials) -> Result<String, String> {
221 +
    let result = probe(&s.url, &credentials, false).await?;
222 +
    sqlx::query("UPDATE repo_storage SET credentials = ?, checked_ms = ?, check_result = ? WHERE repo_id = ?")
223 +
        .bind(app.secret_key.seal(&credentials.to_json()))
224 +
        .bind(now_ms())
225 +
        .bind(&result)
226 +
        .bind(s.repo_id)
227 +
        .execute(&app.db)
228 +
        .await
229 +
        .map_err(|e| e.to_string())?;
230 +
    if let Some(dir) = dir_of(app, s.repo_id).await {
231 +
        remember(dir, &s.url, credentials);
232 +
    }
233 +
    Ok(result)
234 +
}
235 +
236 +
/// A repository is gone (its store row with it): forget its credentials.
237 +
pub fn removed(app: &Shared, name: &crate::repos::RepoName) {
238 +
    forget(&app.repos.dir(name));
239 +
}
240 +
241 +
/// A repository moved to another name (a transfer): its store goes with it.
242 +
pub fn renamed(app: &Shared, from: &crate::repos::RepoName, to: &crate::repos::RepoName) {
243 +
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
244 +
    if let Some(k) = known.as_mut() {
245 +
        if let Some(url) = k.urls.remove(&app.repos.dir(from)) {
246 +
            k.urls.insert(app.repos.dir(to), url);
247 +
        }
248 +
    }
249 +
}
added crates/hub/tests/storage.rs +221 −0
1 +
//! Repositories kept in their owner's bucket (oxygenhub#96): the New
2 +
//! repository form refuses what the hub must not accept and creates
3 +
//! nothing then; with a real S3 test bucket configured (`OXY_TEST_S3` and
4 +
//! `OXY_TEST_S3_PROFILE`, keys read from that profile and handed over), a
5 +
//! repository is made in the bucket, takes a push, shows where it lives,
6 +
//! passes Settings → Storage → Check, and the test removes what it wrote.
7 +
8 +
use axum::body::Body;
9 +
use axum::http::{header, Request, StatusCode};
10 +
use http_body_util::BodyExt;
11 +
use oxy_core::repo::Repo;
12 +
use oxygenhub::config::Config;
13 +
use oxygenhub::repos::RepoName;
14 +
use oxygenhub::{db, Shared};
15 +
use tower::ServiceExt;
16 +
17 +
fn hermetic() {
18 +
    static ONCE: std::sync::Once = std::sync::Once::new();
19 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
20 +
    ONCE.call_once(|| {
21 +
        let d = tempfile::tempdir().unwrap();
22 +
        std::env::set_var("OXY_HOME", d.path());
23 +
        DIR.set(d).ok();
24 +
    });
25 +
}
26 +
27 +
struct Hub {
28 +
    app: Shared,
29 +
    base: String,
30 +
    _dir: tempfile::TempDir,
31 +
}
32 +
33 +
async fn hub() -> Hub {
34 +
    hermetic();
35 +
    let dir = tempfile::tempdir().unwrap();
36 +
    let mut config = Config::load(dir.path()).unwrap();
37 +
    config.auth.push_tokens = vec!["site".into()];
38 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
39 +
    let port = listener.local_addr().unwrap().port();
40 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
41 +
    let app = oxygenhub::build(config).await.unwrap();
42 +
    let router = oxygenhub::router(app.clone());
43 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
44 +
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
45 +
}
46 +
47 +
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> (StatusCode, String, Option<String>) {
48 +
    let mut req = Request::builder().method(method).uri(path);
49 +
    if let Some(c) = cookie {
50 +
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
51 +
    }
52 +
    let body = match form {
53 +
        Some(f) => {
54 +
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
55 +
            Body::from(f.to_string())
56 +
        }
57 +
        None => Body::empty(),
58 +
    };
59 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
60 +
    let status = res.status();
61 +
    let cookie = res
62 +
        .headers()
63 +
        .get_all(header::SET_COOKIE)
64 +
        .iter()
65 +
        .filter_map(|v| v.to_str().ok())
66 +
        .filter_map(|c| c.split(';').next())
67 +
        .filter_map(|kv| kv.strip_prefix("oxyhub="))
68 +
        .map(String::from)
69 +
        .next();
70 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
71 +
    (status, String::from_utf8_lossy(&bytes).into_owned(), cookie)
72 +
}
73 +
74 +
fn csrf_of(page: &str) -> String {
75 +
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
76 +
    page[i..].split('"').next().unwrap().to_string()
77 +
}
78 +
79 +
fn enc(s: &str) -> String {
80 +
    s.bytes()
81 +
        .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
82 +
        .collect()
83 +
}
84 +
85 +
async fn andy(app: &Shared) -> String {
86 +
    let (st, body, cookie) =
87 +
        send(app, "POST", "/register", None, Some("username=andy&email=andy%40example.com&password=correct-horse-battery&invite=")).await;
88 +
    assert_eq!(st, StatusCode::SEE_OTHER, "{body}");
89 +
    cookie.expect("session")
90 +
}
91 +
92 +
async fn new_repo(app: &Shared, me: &str, fields: &str) -> (StatusCode, String) {
93 +
    let csrf = csrf_of(&send(app, "GET", "/new", Some(me), None).await.1);
94 +
    let (st, body, _) = send(app, "POST", "/new", Some(me), Some(&format!("_csrf={csrf}&owner=andy&{fields}"))).await;
95 +
    (st, body)
96 +
}
97 +
98 +
#[tokio::test(flavor = "multi_thread")]
99 +
async fn a_bucket_the_hub_must_not_use_is_refused_and_nothing_is_made() {
100 +
    let h = hub().await;
101 +
    let me = andy(&h.app).await;
102 +
    for (url, why) in [
103 +
        ("s3://b/p?profile=default", "?profile= is not allowed"),
104 +
        ("s3://b/p?endpoint=http%3A%2F%2F169.254.169.254", "?endpoint= is not allowed"),
105 +
        ("gs://b/p?credentials=%2Fetc%2Fkey.json", "?credentials= is not allowed"),
106 +
        ("dir:///var/lib/oxygenhub", "a store is s3://"),
107 +
        ("az://account", "names the account and the container"),
108 +
    ] {
109 +
        let (st, body) =
110 +
            new_repo(&h.app, &me, &format!("name=cloudy&storage=bucket&store_url={}&access_key_id=a&secret_access_key=b", enc(url))).await;
111 +
        assert_eq!(st, StatusCode::OK, "{url}");
112 +
        assert!(body.contains(why), "{url}: {body}");
113 +
    }
114 +
    let (_, body) = new_repo(&h.app, &me, &format!("name=cloudy&storage=bucket&store_url={}&access_key_id=a", enc("s3://b/p"))).await;
115 +
    assert!(body.contains("Credentials: credentials need secret_access_key"), "{body}");
116 +
    let name = RepoName::new("andy", "cloudy").unwrap();
117 +
    assert!(!h.app.repos.exists(&name));
118 +
    assert!(db::repos::get(&h.app.db, "andy", "cloudy").await.unwrap().is_none());
119 +
    // The hub's own disk stays the default.
120 +
    let (st, body) = new_repo(&h.app, &me, "name=local").await;
121 +
    assert_eq!(st, StatusCode::SEE_OTHER, "{body}");
122 +
    let page = send(&h.app, "GET", "/andy/local", Some(&me), None).await.1;
123 +
    assert!(!page.contains(">bucket</span>"));
124 +
}
125 +
126 +
/// The S3 test bucket's URL with only its region kept, and the profile's keys.
127 +
fn s3_test() -> Option<(String, String, String)> {
128 +
    let url = std::env::var("OXY_TEST_S3").ok().filter(|u| !u.is_empty())?;
129 +
    let profile = std::env::var("OXY_TEST_S3_PROFILE").ok().filter(|p| !p.is_empty())?;
130 +
    let (base, query) = url.split_once('?').unwrap_or((&url, ""));
131 +
    let region: Vec<&str> = query.split('&').filter(|p| p.starts_with("region=")).collect();
132 +
    let home = std::path::PathBuf::from(std::env::var("HOME").ok()?);
133 +
    let text = std::fs::read_to_string(home.join(".aws/credentials")).ok()?;
134 +
    let (mut section, mut id, mut secret) = (String::new(), None, None);
135 +
    for line in text.lines().map(str::trim) {
136 +
        if let Some(n) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
137 +
            section = n.to_string();
138 +
        } else if let Some((k, v)) = line.split_once('=') {
139 +
            if section == profile {
140 +
                match k.trim() {
141 +
                    "aws_access_key_id" => id = Some(v.trim().to_string()),
142 +
                    "aws_secret_access_key" => secret = Some(v.trim().to_string()),
143 +
                    _ => {}
144 +
                }
145 +
            }
146 +
        }
147 +
    }
148 +
    let base = format!("{base}/hub-{}", std::process::id());
149 +
    let url = if region.is_empty() { base } else { format!("{base}?{}", region.join("&")) };
150 +
    Some((url, id?, secret?))
151 +
}
152 +
153 +
#[tokio::test(flavor = "multi_thread")]
154 +
async fn a_repository_in_a_real_bucket_takes_a_push_and_passes_its_check() {
155 +
    let Some((url, id, secret)) = s3_test() else {
156 +
        eprintln!("skipped: OXY_TEST_S3 and OXY_TEST_S3_PROFILE are not set");
157 +
        return;
158 +
    };
159 +
    let h = hub().await;
160 +
    let me = andy(&h.app).await;
161 +
    let fields = format!(
162 +
        "name=cloudy&visibility=private&storage=bucket&store_url={}&access_key_id={}&secret_access_key={}",
163 +
        enc(&url),
164 +
        enc(&id),
165 +
        enc(&secret)
166 +
    );
167 +
    let (st, body) = new_repo(&h.app, &me, &fields).await;
168 +
    let credentials = oxy_strata::Credentials::Aws { access_key_id: id, secret_access_key: secret, session_token: None };
169 +
    let store = oxy_strata::CloudStore::open_with(&url, Some(&credentials)).unwrap();
170 +
    let cleanup = || {
171 +
        use oxy_core::strata::Store as _;
172 +
        for e in store.list("").unwrap() {
173 +
            store.delete(&e.key).unwrap();
174 +
        }
175 +
    };
176 +
    if st != StatusCode::SEE_OTHER {
177 +
        cleanup();
178 +
        panic!("{body}");
179 +
    }
180 +
    let result = std::panic::AssertUnwindSafe(async {
181 +
        // A push from a client lands in the bucket.
182 +
        let work = tempfile::tempdir().unwrap();
183 +
        let mut c = Repo::init(work.path()).unwrap();
184 +
        c.config.set("remote.origin.url", &format!("{}/andy/cloudy", h.base)).unwrap();
185 +
        c.config.set("remote.origin.token", "site").unwrap();
186 +
        c.save_config().unwrap();
187 +
        std::fs::write(work.path().join("a.txt"), "kept in a bucket\n").unwrap();
188 +
        let head = c.commit("First", &[]).unwrap();
189 +
        let pushed = tokio::task::spawn_blocking(move || c.push("origin", false).unwrap()).await.unwrap();
190 +
        assert!(pushed.iter().all(|r| r.ok), "{pushed:?}");
191 +
        let hosted = h.app.repos.open(&RepoName::new("andy", "cloudy").unwrap()).unwrap();
192 +
        assert_eq!(hosted.bookmark_heads().unwrap(), vec![head]);
193 +
        {
194 +
            use oxy_core::strata::Store as _;
195 +
            assert!(store.read_ref("ops/head").unwrap().is_some(), "the operation log is in the bucket");
196 +
        }
197 +
        // The pages say where it lives; Settings → Storage checks the bucket.
198 +
        let page = send(&h.app, "GET", "/andy/cloudy", Some(&me), None).await.1;
199 +
        assert!(page.contains(">bucket</span>") && page.contains(url.split('?').next().unwrap()), "{page}");
200 +
        let settings = send(&h.app, "GET", "/andy/cloudy/settings", Some(&me), None).await.1;
201 +
        assert!(settings.contains("id=\"storage\""), "{settings}");
202 +
        let csrf = csrf_of(&settings);
203 +
        let (_, after, _) =
204 +
            send(&h.app, "POST", "/andy/cloudy/settings/storage", Some(&me), Some(&format!("_csrf={csrf}&action=check"))).await;
205 +
        assert!(after.contains("The bucket passed the check: 8 checks passed."), "{after}");
206 +
        // A second repository cannot start in the same place.
207 +
        let (st, body) = new_repo(&h.app, &me, &fields.replace("name=cloudy", "name=again")).await;
208 +
        assert_eq!(st, StatusCode::OK);
209 +
        assert!(body.contains("already holds"), "{body}");
210 +
    });
211 +
    let outcome = futures_catch(result).await;
212 +
    cleanup();
213 +
    if let Err(p) = outcome {
214 +
        std::panic::resume_unwind(p);
215 +
    }
216 +
}
217 +
218 +
async fn futures_catch<F: std::future::Future<Output = ()>>(f: std::panic::AssertUnwindSafe<F>) -> std::thread::Result<()> {
219 +
    use futures::FutureExt;
220 +
    f.catch_unwind().await
221 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A repository can live in its owner's bucket (#96).** The New repository form has a Storage section: this server's disk, or your own Amazon S3, Google Cloud Storage or Azure bucket, with credentials for it. The bucket is checked before anything is made. The credentials are sealed on the server and used only for that repository. The hub keeps a cache in front. The header shows a *bucket* tag, and Settings › Storage checks the bucket again and replaces the credentials. Tested with a push into a real S3 bucket.
8 +
7 9
- **Runners decline, and the queue says why a job waits (oxygenflow#36, OxygenFlow spec §11.4–11.5).** A runner can hand back a job it cannot run with a reason. The job is queued again and not offered to that runner while what it offers stays the same, for ten minutes at most. After three different runners decline it, the job ends as unplaceable with their reasons. Every 15 seconds the hub works out why each ready job no runner can take is waiting, from each runner's last claim: no runner online, a missing tier, the wrong architecture, an image it does not hold, a label, memory or CPUs, a scope, or a decline. The run page and the API show the reason, and a runner's page shows its tiers and what it declined.
8 10
9 11
- **Assistants through the API and `oh` (§13.4).** List, add or change, and remove a repository's, an organisation's or the site's assistants, and read or set their prompts: `/repos/{o}/{n}/assistants`, `/orgs/{org}/assistants`, `/admin/assistants`; `oh assistant list|add|remove|prompt` with `--org` or `--site`. Tokens are write-only and read from an environment variable by `oh`.
…
119 unchanged lines
modified Cargo.lock +208 −7
…
35 unchanged lines
36 36
]
37 37
38 38
[[package]]
39 +
name = "android_system_properties"
40 +
version = "0.1.6"
41 +
source = "registry+https://github.com/rust-lang/crates.io-index"
42 +
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
43 +
dependencies = [
44 +
 "libc",
45 +
]
46 +
47 +
[[package]]
39 48
name = "anstream"
40 49
version = "1.0.0"
41 50
source = "registry+https://github.com/rust-lang/crates.io-index"
…
515 unchanged lines
557 566
]
558 567
559 568
[[package]]
569 +
name = "chrono"
570 +
version = "0.4.45"
571 +
source = "registry+https://github.com/rust-lang/crates.io-index"
572 +
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
573 +
dependencies = [
574 +
 "iana-time-zone",
575 +
 "num-traits",
576 +
 "serde",
577 +
 "windows-link",
578 +
]
579 +
580 +
[[package]]
560 581
name = "clap"
561 582
version = "4.6.7"
562 583
source = "registry+https://github.com/rust-lang/crates.io-index"
…
131 unchanged lines
694 715
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
695 716
696 717
[[package]]
718 +
name = "crc-fast"
719 +
version = "1.10.0"
720 +
dependencies = [
721 +
 "digest 0.10.7",
722 +
 "spin 0.10.1",
723 +
]
724 +
725 +
[[package]]
697 726
name = "crc32fast"
698 727
version = "1.5.2"
699 728
source = "registry+https://github.com/rust-lang/crates.io-index"
…
403 unchanged lines
1103 1132
dependencies = [
1104 1133
 "futures-core",
1105 1134
 "futures-sink",
1106 -
 "spin",
1135 +
 "spin 0.9.9",
1107 1136
]
1108 1137
1109 1138
[[package]]
…
326 unchanged lines
1436 1465
]
1437 1466
1438 1467
[[package]]
1468 +
name = "humantime"
1469 +
version = "2.4.0"
1470 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1471 +
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
1472 +
1473 +
[[package]]
1439 1474
name = "hybrid-array"
1440 1475
version = "0.4.15"
1441 1476
source = "registry+https://github.com/rust-lang/crates.io-index"
…
63 unchanged lines
1505 1540
 "tokio",
1506 1541
 "tower-service",
1507 1542
 "tracing",
1543 +
]
1544 +
1545 +
[[package]]
1546 +
name = "iana-time-zone"
1547 +
version = "0.1.65"
1548 +
dependencies = [
1549 +
 "android_system_properties",
1550 +
 "iana-time-zone-haiku",
1551 +
 "js-sys",
1552 +
 "log",
1553 +
 "wasm-bindgen",
1554 +
 "windows-core",
1555 +
]
1556 +
1557 +
[[package]]
1558 +
name = "iana-time-zone-haiku"
1559 +
version = "0.1.2"
1560 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1561 +
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1562 +
dependencies = [
1563 +
 "cc",
1508 1564
]
1509 1565
1510 1566
[[package]]
…
165 unchanged lines
1676 1732
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1677 1733
1678 1734
[[package]]
1735 +
name = "itertools"
1736 +
version = "0.15.0"
1737 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1738 +
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
1739 +
dependencies = [
1740 +
 "either",
1741 +
]
1742 +
1743 +
[[package]]
1679 1744
name = "itoa"
1680 1745
version = "1.0.18"
1681 1746
source = "registry+https://github.com/rust-lang/crates.io-index"
…
48 unchanged lines
1730 1795
source = "registry+https://github.com/rust-lang/crates.io-index"
1731 1796
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1732 1797
dependencies = [
1733 -
 "spin",
1798 +
 "spin 0.9.9",
1734 1799
]
1735 1800
1736 1801
[[package]]
…
147 unchanged lines
1884 1949
dependencies = [
1885 1950
 "cfg-if",
1886 1951
 "digest 0.10.7",
1952 +
]
1953 +
1954 +
[[package]]
1955 +
name = "md-5"
1956 +
version = "0.11.0"
1957 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1958 +
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
1959 +
dependencies = [
1960 +
 "cfg-if",
1961 +
 "digest 0.11.3",
1887 1962
]
1888 1963
1889 1964
[[package]]
…
117 unchanged lines
2007 2082
 "httparse",
2008 2083
 "memchr",
2009 2084
 "mime",
2010 -
 "spin",
2085 +
 "spin 0.9.9",
2011 2086
 "version_check",
2012 2087
]
2013 2088
…
117 unchanged lines
2131 2206
dependencies = [
2132 2207
 "autocfg",
2133 2208
 "libm",
2209 +
]
2210 +
2211 +
[[package]]
2212 +
name = "object_store"
2213 +
version = "0.14.2"
2214 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2215 +
checksum = "f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94"
2216 +
dependencies = [
2217 +
 "async-trait",
2218 +
 "base64 0.23.1",
2219 +
 "bytes",
2220 +
 "chrono",
2221 +
 "crc-fast",
2222 +
 "form_urlencoded",
2223 +
 "futures-channel",
2224 +
 "futures-core",
2225 +
 "futures-util",
2226 +
 "http",
2227 +
 "http-body-util",
2228 +
 "httparse",
2229 +
 "humantime",
2230 +
 "hyper",
2231 +
 "itertools",
2232 +
 "md-5 0.11.0",
2233 +
 "parking_lot",
2234 +
 "percent-encoding",
2235 +
 "quick-xml 0.41.0",
2236 +
 "rand 0.10.3",
2237 +
 "ring",
2238 +
 "rustls-pki-types",
2239 +
 "serde",
2240 +
 "serde_json",
2241 +
 "serde_urlencoded",
2242 +
 "thiserror 2.0.21",
2243 +
 "tokio",
2244 +
 "tracing",
2245 +
 "url",
2246 +
 "wasm-bindgen-futures",
2247 +
 "web-time",
2134 2248
]
2135 2249
2136 2250
[[package]]
…
71 unchanged lines
2208 2322
]
2209 2323
2210 2324
[[package]]
2325 +
name = "oxy-strata"
2326 +
version = "0.4.0"
2327 +
dependencies = [
2328 +
 "async-trait",
2329 +
 "bytes",
2330 +
 "futures",
2331 +
 "http",
2332 +
 "http-body-util",
2333 +
 "object_store",
2334 +
 "oxy-core",
2335 +
 "rustls",
2336 +
 "serde_json",
2337 +
 "tokio",
2338 +
 "ureq",
2339 +
]
2340 +
2341 +
[[package]]
2211 2342
name = "oxygenhub"
2212 2343
version = "0.4.0"
2213 2344
dependencies = [
…
18 unchanged lines
2232 2363
 "image",
2233 2364
 "lettre",
2234 2365
 "lzma-rs",
2235 -
 "md-5",
2366 +
 "md-5 0.10.6",
2236 2367
 "oxy-core",
2237 2368
 "oxy-server",
2369 +
 "oxy-strata",
2238 2370
 "pulldown-cmark",
2239 2371
 "qrcode",
2240 2372
 "rand 0.8.8",
…
192 unchanged lines
2433 2565
dependencies = [
2434 2566
 "base64 0.23.1",
2435 2567
 "indexmap",
2436 -
 "quick-xml",
2568 +
 "quick-xml 0.42.0",
2437 2569
 "serde",
2438 2570
 "time",
2439 2571
]
…
85 unchanged lines
2525 2657
version = "2.0.1"
2526 2658
source = "registry+https://github.com/rust-lang/crates.io-index"
2527 2659
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
2660 +
2661 +
[[package]]
2662 +
name = "quick-xml"
2663 +
version = "0.41.0"
2664 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2665 +
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
2666 +
dependencies = [
2667 +
 "memchr",
2668 +
 "serde",
2669 +
]
2528 2670
2529 2671
[[package]]
2530 2672
name = "quick-xml"
…
697 unchanged lines
3228 3370
dependencies = [
3229 3371
 "lock_api",
3230 3372
]
3373 +
3374 +
[[package]]
3375 +
name = "spin"
3376 +
version = "0.10.1"
3377 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3378 +
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
3231 3379
3232 3380
[[package]]
3233 3381
name = "spki"
…
129 unchanged lines
3363 3511
 "hmac",
3364 3512
 "itoa",
3365 3513
 "log",
3366 -
 "md-5",
3514 +
 "md-5 0.10.6",
3367 3515
 "memchr",
3368 3516
 "once_cell",
3369 3517
 "percent-encoding",
…
31 unchanged lines
3401 3549
 "home",
3402 3550
 "itoa",
3403 3551
 "log",
3404 -
 "md-5",
3552 +
 "md-5 0.10.6",
3405 3553
 "memchr",
3406 3554
 "once_cell",
3407 3555
 "rand 0.8.8",
…
842 unchanged lines
4250 4398
]
4251 4399
4252 4400
[[package]]
4401 +
name = "windows-core"
4402 +
version = "0.62.2"
4403 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4404 +
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
4405 +
dependencies = [
4406 +
 "windows-implement",
4407 +
 "windows-interface",
4408 +
 "windows-link",
4409 +
 "windows-result",
4410 +
 "windows-strings",
4411 +
]
4412 +
4413 +
[[package]]
4414 +
name = "windows-implement"
4415 +
version = "0.60.2"
4416 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4417 +
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
4418 +
dependencies = [
4419 +
 "proc-macro2",
4420 +
 "quote",
4421 +
 "syn 2.0.119",
4422 +
]
4423 +
4424 +
[[package]]
4425 +
name = "windows-interface"
4426 +
version = "0.59.3"
4427 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4428 +
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
4429 +
dependencies = [
4430 +
 "proc-macro2",
4431 +
 "quote",
4432 +
 "syn 2.0.119",
4433 +
]
4434 +
4435 +
[[package]]
4253 4436
name = "windows-link"
4254 4437
version = "0.2.1"
4255 4438
source = "registry+https://github.com/rust-lang/crates.io-index"
4256 4439
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4440 +
4441 +
[[package]]
4442 +
name = "windows-result"
4443 +
version = "0.4.1"
4444 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4445 +
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4446 +
dependencies = [
4447 +
 "windows-link",
4448 +
]
4449 +
4450 +
[[package]]
4451 +
name = "windows-strings"
4452 +
version = "0.5.1"
4453 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4454 +
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4455 +
dependencies = [
4456 +
 "windows-link",
4457 +
]
4257 4458
4258 4459
[[package]]
4259 4460
name = "windows-sys"
…
394 unchanged lines
modified Cargo.toml +7 −0
…
15 unchanged lines
16 16
[workspace.dependencies]
17 17
oxy-core = { path = "deps/oxygenflow/deps/oxygen/crates/oxy-core" }
18 18
oxy-server = { path = "deps/oxygenflow/deps/oxygen/crates/oxy-server" }
19 +
oxy-strata = { path = "deps/oxygenflow/deps/oxygen/crates/oxy-strata" }
19 20
hub-api = { path = "crates/hub-api" }
20 21
flow-core = { path = "deps/oxygenflow/crates/flow-core" }
21 22
…
41 unchanged lines
63 64
64 65
[profile.release]
65 66
debug = 1
67 +
68 +
# The cloud stores' two patched dependencies, as oxygen patches them so
69 +
# the macOS cross build links (see their PATCHED.md in the pinned oxygen).
70 +
[patch.crates-io]
71 +
crc-fast = { path = "deps/oxygenflow/deps/oxygen/vendor/crc-fast" }
72 +
iana-time-zone = { path = "deps/oxygenflow/deps/oxygen/vendor/iana-time-zone" }
modified crates/hub/Cargo.toml +1 −0
…
20 unchanged lines
21 21
tree-sitter-bash = "0.25"
22 22
oxy-core.workspace = true
23 23
oxy-server.workspace = true
24 +
oxy-strata.workspace = true
24 25
hub-api.workspace = true
25 26
flow-core.workspace = true
26 27
anyhow.workspace = true
…
55 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
25 unchanged lines
26 26
pub mod secrets;
27 27
pub mod signin;
28 28
pub mod social;
29 +
pub mod storage;
29 30
pub mod variables;
30 31
pub mod web;
31 32
pub mod wiki;
…
79 unchanged lines
111 112
        started: std::time::Instant::now(),
112 113
    });
113 114
    *slot.lock().unwrap() = Some(Arc::downgrade(&app));
115 +
    storage::load(&app).await?;
114 116
    tokio::spawn(hooks::worker(app.clone()));
115 117
    tokio::spawn(flow::reaper(app.clone()));
116 118
    tokio::spawn(flow::scheduler(app.clone()));
…
46 unchanged lines
modified crates/hub/src/repos/mod.rs +28 −0
…
88 unchanged lines
89 89
        })
90 90
    }
91 91
92 +
    /// Create an empty, unbound bare repository whose objects and
93 +
    /// operation log are kept in the store at `url` (oxygenhub#96); the
94 +
    /// store's credentials must already be known to the opener.
95 +
    pub fn create_on(&self, name: &RepoName, url: &str) -> Result<Repo, RepoError> {
96 +
        let dir = self.dir(name);
97 +
        if dir.exists() {
98 +
            return Err(RepoError::Exists(name.to_string()));
99 +
        }
100 +
        let made = (|| -> Result<Repo, RepoError> {
101 +
            drop(Repo::init_bare_on(&dir, [0; 16], Some(url))?);
102 +
            // Unbound as `create` makes them: oxy-core's marker file in
103 +
            // place of the id, so the first push gives the id.
104 +
            let oxy = dir.join(oxy_core::repo::OXY_DIR);
105 +
            std::fs::remove_file(oxy.join("id"))?;
106 +
            std::fs::write(oxy.join("unbound"), b"")?;
107 +
            Ok(Repo::open(&dir)?)
108 +
        })();
109 +
        if made.is_err() {
110 +
            let _ = std::fs::remove_dir_all(&dir);
111 +
        }
112 +
        made
113 +
    }
114 +
92 115
    /// Fork `src` into `dest` (hub spec §4): a new bare repository with its
93 116
    /// own id, the source's objects hard-linked where the file system allows
94 117
    /// (copied otherwise; objects are immutable, so sharing them is safe),
…
7 unchanged lines
102 125
        }
103 126
        if to.exists() {
104 127
            return Err(RepoError::Exists(dest.to_string()));
128 +
        }
129 +
        if Repo::open(&from).is_ok_and(|r| r.config.storage_url.is_some()) {
130 +
            return Err(RepoError::Io(std::io::Error::other(
131 +
                "a repository kept in a bucket cannot be forked yet: clone it and push the clone to a new repository",
132 +
            )));
105 133
        }
106 134
        tokio::task::spawn_blocking(move || {
107 135
            let _guard = guard;
…
141 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
241 unchanged lines
242 242
        .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack))
243 243
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
244 244
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
245 +
        .route("/{owner}/{name}/settings/storage", post(repo_settings::storage))
245 246
        .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token))
246 247
        .route("/{owner}/{name}/settings/runners/{id}/remove", post(repo_settings::runner_remove))
247 248
        .route("/{owner}/{name}/issues", get(issues::list))
…
155 unchanged lines
403 404
    pub archived: bool,
404 405
    /// Whether the repository has a wiki (§6.11).
405 406
    pub wiki: bool,
407 +
    /// The bucket its data lives in, when not this hub's disk (#96).
408 +
    pub stored_in: Option<String>,
406 409
}
407 410
408 411
impl RepoShell {
…
12 unchanged lines
421 424
            open_proposals: row.open_proposals,
422 425
            archived: row.archived,
423 426
            wiki: crate::repos::RepoName::new(&row.owner, &row.name).is_some_and(|n| app.wikis.exists(&n)),
427 +
            stored_in: crate::storage::location(app, &row.owner, &row.name),
424 428
        }
425 429
    }
426 430
…
276 unchanged lines
modified crates/hub/src/web/repo_settings.rs +117 −0
…
44 unchanged lines
45 45
    description: String,
46 46
    #[serde(default = "default_visibility")]
47 47
    visibility: String,
48 +
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
49 +
    #[serde(default)]
50 +
    storage: String,
51 +
    #[serde(flatten)]
52 +
    bucket: BucketForm,
53 +
}
54 +
55 +
/// A bucket and the credentials for it, as the New repository form and
56 +
/// Settings → Storage give them; only the kind the URL names is read.
57 +
#[derive(Deserialize, Default)]
58 +
pub struct BucketForm {
59 +
    #[serde(default)]
60 +
    store_url: String,
61 +
    #[serde(default)]
62 +
    access_key_id: String,
63 +
    #[serde(default)]
64 +
    secret_access_key: String,
65 +
    #[serde(default)]
66 +
    service_account_key: String,
67 +
    #[serde(default)]
68 +
    tenant_id: String,
69 +
    #[serde(default)]
70 +
    client_id: String,
71 +
    #[serde(default)]
72 +
    client_secret: String,
48 73
}
49 74
75 +
impl BucketForm {
76 +
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
77 +
        crate::storage::credentials_from(
78 +
            scheme,
79 +
            &self.access_key_id,
80 +
            &self.secret_access_key,
81 +
            &self.service_account_key,
82 +
            &self.tenant_id,
83 +
            &self.client_id,
84 +
            &self.client_secret,
85 +
        )
86 +
    }
87 +
}
88 +
50 89
fn default_visibility() -> String {
51 90
    "public".into()
52 91
}
…
18 unchanged lines
71 110
        || app.repos.exists(&name)
72 111
    {
73 112
        return Ok(fail("That repository exists."));
113 +
    }
114 +
    if f.storage == "bucket" {
115 +
        // Kept in the owner's bucket (oxygenhub#96): checked before
116 +
        // anything is made, and nothing is left behind when it fails.
117 +
        let url = f.bucket.store_url.trim();
118 +
        let parsed = match crate::storage::check_url(url) {
119 +
            Ok(p) => p,
120 +
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
121 +
        };
122 +
        let credentials = match f.bucket.credentials(&parsed.scheme) {
123 +
            Ok(c) => c,
124 +
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
125 +
        };
126 +
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
127 +
            .await
128 +
            .map_err(|e| Error::from_db(shell.clone(), e))?;
129 +
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
130 +
            db::repos::delete(&app.db, row.id).await.ok();
131 +
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
132 +
        }
133 +
        accounts::audit(
134 +
            &app.db,
135 +
            Some(&me.account),
136 +
            "repo.created",
137 +
            &name.to_string(),
138 +
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
139 +
        )
140 +
        .await
141 +
        .ok();
142 +
        return Ok(Redirect::to(&name.url()).into_response());
74 143
    }
75 144
    app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?;
76 145
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
…
32 unchanged lines
109 178
    variables: super::variables::Panel,
110 179
    /// The repository's Flow secrets (#107).
111 180
    secrets: super::secrets::Panel,
181 +
    /// Where its data lives, when in a bucket (oxygenhub#96).
182 +
    storage: Option<crate::storage::Storage>,
112 183
}
113 184
114 185
async fn settings_page(
…
36 unchanged lines
151 222
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
152 223
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
153 224
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
225 +
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
154 226
    Ok(Page(RepoSettingsPage {
227 +
        storage,
155 228
        assistants,
156 229
        variables,
157 230
        secrets,
…
176 unchanged lines
334 407
    {
335 408
        let _guard = app.repos.write_guard(&repo).await;
336 409
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
410 +
        crate::storage::renamed(&app, &repo, &dest);
337 411
        if app.wikis.exists(&repo) {
338 412
            app.wikis.rename(&repo, &dest).ok();
339 413
        }
…
99 unchanged lines
439 513
    }
440 514
    let _guard = app.repos.write_guard(&repo).await;
441 515
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
516 +
    // A repository kept in a bucket leaves its data there: the owner's.
517 +
    crate::storage::removed(&app, &repo);
442 518
    crate::wiki::trash(&app, &repo).ok();
443 519
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
444 520
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
…
103 unchanged lines
548 624
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
549 625
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
550 626
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
627 +
}
628 +
629 +
#[derive(Deserialize)]
630 +
pub struct StorageForm {
631 +
    #[serde(rename = "_csrf")]
632 +
    csrf_token: String,
633 +
    /// `check`, or `credentials` (with the new ones).
634 +
    action: String,
635 +
    #[serde(flatten)]
636 +
    bucket: BucketForm,
637 +
}
638 +
639 +
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
640 +
/// its credentials once the new ones pass the check.
641 +
pub async fn storage(
642 +
    State(app): State<Shared>,
643 +
    viewer: Viewer,
644 +
    Path((owner, name)): Path<(String, String)>,
645 +
    Form(f): Form<StorageForm>,
646 +
) -> Result<Page<RepoSettingsPage>, Error> {
647 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
648 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
649 +
    csrf(&viewer, &f.csrf_token, &shell)?;
650 +
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
651 +
        return settings_page(&app, shell, row, role, None, Some("This repository is kept on this server's disk.".into())).await;
652 +
    };
653 +
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
654 +
    let outcome = match f.action.as_str() {
655 +
        "credentials" => match f.bucket.credentials(&scheme) {
656 +
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
657 +
            Err(e) => Err(format!("Credentials: {e}")),
658 +
        },
659 +
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
660 +
    };
661 +
    match outcome {
662 +
        Ok(notice) => {
663 +
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
664 +
            settings_page(&app, shell, row, role, Some(notice), None).await
665 +
        }
666 +
        Err(e) => settings_page(&app, shell, row, role, None, Some(format!("Storage: {e}"))).await,
667 +
    }
551 668
}
552 669
553 670
#[derive(Deserialize)]
…
182 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +10 −0
…
141 unchanged lines
142 142
143 143
Deletion moves the directory to `<data>/trash/<owner>/<name>-<timestamp>` and a job removes it after `trash_days` (default 30). **Transfer** (oxygenhub#108, Settings › Advanced, the repository's admins) moves it to a user or organisation the admin may create repositories under, keeping its name: a directory move under the repository's write lock (the Git mirror moves with it), the row's owner, and a `repo_redirects` row so the old address redirects on the web (the rest of the path kept) and still resolves for `oxy` remotes and the API. **Archive** marks it read-only with a reason: pushes are refused by the wire protocol, and the web refuses edits, issues, proposals, comments and reviews, while reading, cloning and the admins' settings go on; the header shows an *archived* badge and the admins bring it back from the same place. Both are audited (`repo.transferred`, `repo.archived`, `repo.unarchived`). The **Advanced** section also holds Delete and, ahead of wikis (oxygenhub#104), a disabled Delete wiki data.
144 144
145 +
### 4.1a Repositories in the owner's bucket (oxygenhub#96)
146 +
147 +
A repository can keep its data in its owner's bucket instead of on the hub's disk.
148 +
149 +
- **Creating:** the New repository form's Storage section takes a store URL (`s3://BUCKET/PREFIX`, `gs://BUCKET/PREFIX` or `az://ACCOUNT/CONTAINER/PREFIX`, with `?region=` the only setting allowed) and the credentials for it: an S3 access key, a Google service account key, or an Azure service principal. The hub refuses a profile, a key file, an endpoint or `dir://`, which would make it use its own host's credentials, reach its own network or write to its own disk.
150 +
- **The check:** before anything is made, the bucket or prefix must be empty and pass the store conformance check under a probe prefix. Nothing is created when either fails.
151 +
- **What the hub keeps:** a bare repository directory whose `storage.url` names the store, with a cache in front (oxygen#68), and a `repo_storage` row holding the URL and the credentials, sealed under `keys/secrets.key`. At start the hub hands every repository's credentials to the cloud stores by URL, so nothing from its host's environment or cloud profiles is used for a repository.
152 +
- **Pages:** the header shows a *bucket* tag; its title names the URL to the repository's admins only. Settings › Storage shows the store and the last check, checks it again, and replaces the credentials once new ones pass the check. Both are audited as `repo.storage`.
153 +
- **Not yet:** forking such a repository is refused (clone and push instead). Deleting one leaves its data in the bucket, which is the owner's. An organisation storage policy, the Sync status and runs on references moved outside the hub are still to come.
154 +
145 155
### 4.2 Forks
146 156
147 157
A fork copies the object store (hard links where the file system allows, otherwise a copy) and records `forked_from`. Because objects are content addressed and repository ids differ, a proposal from a fork carries the changes across repositories by hash; the hub fetches missing objects from the fork's store directly, never over the network.
…
571 unchanged lines
modified templates/repo_new.html +23 −0
…
15 unchanged lines
16 16
    <label><input type="radio" name="visibility" value="internal"> internal (anyone signed in)</label>
17 17
    <label><input type="radio" name="visibility" value="private"> private</label>
18 18
  </div>
19 +
  <label>Storage</label>
20 +
  <div class="choice">
21 +
    <label><input type="radio" name="storage" value="hub" checked> this server's disk</label>
22 +
    <label><input type="radio" name="storage" value="bucket"> my own bucket (Amazon S3, Google Cloud Storage or Azure)</label>
23 +
  </div>
24 +
  <fieldset class="storage-bucket">
25 +
    <legend>Your bucket <small class="mute">(only when kept in your own bucket)</small></legend>
26 +
    <p class="small mute">The repository's data lives in the bucket and this server keeps a cache. The bucket, or the prefix in it, must be empty. It is checked before anything is created. The credentials are sealed on this server and used only for this repository; give them read, write, list and delete under the prefix and nothing more.</p>
27 +
    <label for="store_url">Store URL</label><input type="text" id="store_url" name="store_url" placeholder="s3://my-bucket/repos/name?region=eu-west-2, gs://my-bucket/name, az://account/container/name" autocomplete="off">
28 +
    <p class="small mute">Amazon S3: an access key.</p>
29 +
    <div class="row">
30 +
      <div><label for="access_key_id">Access key ID</label><input type="text" id="access_key_id" name="access_key_id" autocomplete="off"></div>
31 +
      <div><label for="secret_access_key">Secret access key</label><input type="password" id="secret_access_key" name="secret_access_key" autocomplete="new-password"></div>
32 +
    </div>
33 +
    <p class="small mute">Google Cloud Storage: a service account key, the JSON file Google gives you.</p>
34 +
    <label for="service_account_key">Service account key</label><textarea id="service_account_key" name="service_account_key" rows="3" autocomplete="off" spellcheck="false"></textarea>
35 +
    <p class="small mute">Azure: a service principal with a role on the container.</p>
36 +
    <div class="row">
37 +
      <div><label for="tenant_id">Tenant ID</label><input type="text" id="tenant_id" name="tenant_id" autocomplete="off"></div>
38 +
      <div><label for="client_id">Client ID</label><input type="text" id="client_id" name="client_id" autocomplete="off"></div>
39 +
      <div><label for="client_secret">Client secret</label><input type="password" id="client_secret" name="client_secret" autocomplete="new-password"></div>
40 +
    </div>
41 +
  </fieldset>
19 42
  <button type="submit">CREATE</button>
20 43
</form>
21 44
{% endblock %}
modified templates/repo_settings.html +31 −0
…
21 unchanged lines
22 22
  <a href="#variables">Variables</a>
23 23
  <a href="#secrets">Secrets</a>
24 24
  <a href="#assistants">AI assistants</a>
25 +
  {% if storage.is_some() %}<a href="#storage">Storage</a>{% endif %}
25 26
  <a href="#collaborators">Collaborators</a>
26 27
  <a href="#advanced">Advanced</a>{% endif %}
27 28
</nav>
…
70 unchanged lines
98 99
{% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %}
99 100
{% call se::panel(repo.url ~ "/settings", secrets, shell.csrf, "This repository's jobs may use these, beside the ones the site and its owner set.") %}
100 101
{% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %}
102 +
{% if role == crate::auth::Role::Admin %}{% if let Some(st) = storage %}<section class="panel" id="storage">
103 +
  <h2>Storage</h2>
104 +
  <p>This repository's data lives in <code>{{ st.url }}</code>, your bucket. This server keeps a cache and the credentials, sealed.</p>
105 +
  <dl class="facts">
106 +
    <dt>Since</dt><dd><time title="{{ st.created_ms|when_i }}">{{ st.created_ms|ago_i }}</time></dd>
107 +
    <dt>Last check</dt><dd>{% if let Some(t) = st.checked_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>: {{ st.check_result }}{% else %}<span class="mute">never</span>{% endif %}</dd>
108 +
  </dl>
109 +
  <form class="inline" method="post" action="{{ repo.url }}/settings/storage">
110 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
111 +
    <input type="hidden" name="action" value="check">
112 +
    <button type="submit" title="Write, read, list and delete under a probe prefix of the bucket, and compare-and-swap a record">CHECK THE BUCKET</button>
113 +
  </form>
114 +
  <details>
115 +
    <summary>Replace the credentials</summary>
116 +
    <form class="card" method="post" action="{{ repo.url }}/settings/storage">
117 +
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
118 +
      <input type="hidden" name="action" value="credentials">
119 +
      <p class="small mute">The new credentials must pass the check before they replace the old ones. Fill the fields for your provider.</p>
120 +
      {% if st.url.starts_with("s3://") %}<div class="row">
121 +
        <div><label for="sak">Access key ID</label><input type="text" id="sak" name="access_key_id" autocomplete="off" required></div>
122 +
        <div><label for="ssk">Secret access key</label><input type="password" id="ssk" name="secret_access_key" autocomplete="new-password" required></div>
123 +
      </div>{% else if st.url.starts_with("gs://") %}<label for="ssa">Service account key</label><textarea id="ssa" name="service_account_key" rows="3" required spellcheck="false"></textarea>{% else %}<div class="row">
124 +
        <div><label for="stn">Tenant ID</label><input type="text" id="stn" name="tenant_id" autocomplete="off" required></div>
125 +
        <div><label for="sci">Client ID</label><input type="text" id="sci" name="client_id" autocomplete="off" required></div>
126 +
        <div><label for="scs">Client secret</label><input type="password" id="scs" name="client_secret" autocomplete="new-password" required></div>
127 +
      </div>{% endif %}
128 +
      <button type="submit">SAVE</button>
129 +
    </form>
130 +
  </details>
131 +
</section>{% endif %}{% endif %}
101 132
<section class="panel" id="collaborators">
102 133
  <h2>Collaborators</h2>
103 134
  {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %}
…
66 unchanged lines
modified templates/repo_tabs.html +1 −1
1 1
{% macro tabs(repo, tab) %}
2 2
<nav class="tabs" aria-label="Repository">
3 3
  <div class="tabs-in">
4 -
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span></span>
4 +
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="tag" title="This repository's data lives in {% if repo.can(crate::auth::Access::Admin) %}{{ b }}, {% endif %}its owner's bucket">bucket</span>{% endif %}</span>
5 5
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a>
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
…
68 unchanged lines