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 |