OxygenFlow claims list the state's pinned Oxygen subrepos that the pusher can read, and the job token may read those repositories too fd3db644df22
refs andymccall/oxygenflow#4
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 18:40 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
fd3db644df22ab7cfb1f7a7426ca4b00000e59c4dbd2149c56ad158fd0d083f0- Depends on
- 19bb31ff1f5c 42fa95540468 d23464c0ac60
- Depended on by
- 5d90e0e045e2 cd437c4f5f8d 5d54cf746e00 4d2e44d79b03 4ac01a732552 5dead1f4f26d b0612e7dce30 afd0be382257 a4f01d51c2a7 cc19697f8871 9a065bc0ce33 941abdd6bb4c 76bc5a058351 25091e5c3bba 370035a46397
- 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.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 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
- 13 atoms, 1 tree operation, 5 files
Browse the tree at this change
added crates/hub/migrations/0017_flow_subrepos.sql +3 −0
| 1 | + | -- Repositories a job's token may also read: those its state pins as |
|
| 2 | + | -- subrepos and its pusher can read (OxygenFlow: subrepo inputs). |
|
| 3 | + | ALTER TABLE flow_jobs ADD COLUMN also_read TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/db/flow.rs +15 −0
| … | 47 unchanged lines |
||
| 48 | 48 | pub receipt: String, |
|
| 49 | 49 | pub receipt_hash: String, |
|
| 50 | 50 | pub log_count: i64, |
|
| 51 | + | /// Comma-separated ids of other repositories the job token may read. |
|
| 52 | + | pub also_read: String, |
|
| 51 | 53 | } |
|
| 52 | 54 | ||
| 53 | 55 | impl Job { |
|
| … | 108 unchanged lines |
||
| 162 | 164 | ||
| 163 | 165 | /// Take the oldest queued job whose dependencies all passed and whose tier |
|
| 164 | 166 | /// the runner offers, and mark it running for `runner` with a token. |
|
| 167 | + | /// Let a job's token also read these repositories (its pinned subrepos). |
|
| 168 | + | pub async fn set_also_read(pool: &Pool, job_id: i64, repo_ids: &[i64]) -> sqlx::Result<()> { |
|
| 169 | + | let list: Vec<String> = repo_ids.iter().map(|i| i.to_string()).collect(); |
|
| 170 | + | sqlx::query("UPDATE flow_jobs SET also_read = ? WHERE id = ?").bind(list.join(",")).bind(job_id).execute(pool).await?; |
|
| 171 | + | Ok(()) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | impl Job { |
|
| 175 | + | pub fn also_read_ids(&self) -> Vec<i64> { |
|
| 176 | + | self.also_read.split(',').filter_map(|x| x.trim().parse().ok()).collect() |
|
| 177 | + | } |
|
| 178 | + | } |
|
| 179 | + | ||
| 165 | 180 | pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> { |
|
| 166 | 181 | let mut tx = pool.begin().await?; |
|
| 167 | 182 | let queued: Vec<Job> = sqlx::query_as( |
|
| … | 151 unchanged lines |
modified crates/hub/src/flow.rs +69 −0
| … | 147 unchanged lines |
||
| 148 | 148 | job: serde_json::from_str(&job.spec).map_err(bad)?, |
|
| 149 | 149 | sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?, |
|
| 150 | 150 | timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60, |
|
| 151 | + | subrepos: vec![], |
|
| 151 | 152 | }; |
|
| 153 | + | let mut claim = claim; |
|
| 154 | + | let (inputs, ids) = subrepo_inputs(&app, &row, &run).await; |
|
| 155 | + | if !ids.is_empty() { |
|
| 156 | + | fdb::set_also_read(&app.db, job.id, &ids).await?; |
|
| 157 | + | } |
|
| 158 | + | claim.subrepos = inputs; |
|
| 152 | 159 | Ok(Json(claim).into_response()) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | /// The Oxygen subrepos the run's state pins that live on this hub and that |
|
| 163 | + | /// its pusher may read: what the job's workspace needs beside its own tree. |
|
| 164 | + | /// A pin the hub cannot serve is left out; the job then fails on the missing |
|
| 165 | + | /// files with an ordinary error. |
|
| 166 | + | async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) { |
|
| 167 | + | let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) }; |
|
| 168 | + | let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); |
|
| 169 | + | let pins = app |
|
| 170 | + | .repos |
|
| 171 | + | .read(&name, move |repo, cache| { |
|
| 172 | + | let loaded = cache.load(repo, &heads)?; |
|
| 173 | + | let mut out = Vec::new(); |
|
| 174 | + | for (path, f) in &loaded.snapshot.files { |
|
| 175 | + | if f.kind != oxy_core::objects::NodeKind::Subrepo { |
|
| 176 | + | continue; |
|
| 177 | + | } |
|
| 178 | + | if let oxy_core::state::FileContent::Opaque { blobs } = &f.content { |
|
| 179 | + | if let Some(b) = blobs.first() { |
|
| 180 | + | if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo |
|
| 181 | + | .read_blob(b) |
|
| 182 | + | .map_err(crate::repos::RepoError::Oxy) |
|
| 183 | + | .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into()))) |
|
| 184 | + | { |
|
| 185 | + | out.push((path.clone(), id, heads)); |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | } |
|
| 190 | + | Ok(out) |
|
| 191 | + | }) |
|
| 192 | + | .await |
|
| 193 | + | .unwrap_or_default(); |
|
| 194 | + | if pins.is_empty() { |
|
| 195 | + | return (vec![], vec![]); |
|
| 196 | + | } |
|
| 197 | + | let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten(); |
|
| 198 | + | let mut inputs = Vec::new(); |
|
| 199 | + | let mut ids = Vec::new(); |
|
| 200 | + | let rows = db::repos::list(&app.db).await.unwrap_or_default(); |
|
| 201 | + | for (path, id, heads) in pins { |
|
| 202 | + | for other in &rows { |
|
| 203 | + | let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue }; |
|
| 204 | + | let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false); |
|
| 205 | + | if !matches { |
|
| 206 | + | continue; |
|
| 207 | + | } |
|
| 208 | + | let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten(); |
|
| 209 | + | if crate::auth::roles::allows(role, crate::auth::Access::Read) { |
|
| 210 | + | inputs.push(flow_core::api::SubrepoInput { |
|
| 211 | + | path: path.clone(), |
|
| 212 | + | repo: format!("{}/{}", other.owner, other.name), |
|
| 213 | + | clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name), |
|
| 214 | + | heads: heads.iter().map(|h| h.to_hex()).collect(), |
|
| 215 | + | }); |
|
| 216 | + | ids.push(other.id); |
|
| 217 | + | } |
|
| 218 | + | break; |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 221 | + | (inputs, ids) |
|
| 153 | 222 | } |
|
| 154 | 223 | ||
| 155 | 224 | async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> { |
|
| … | 98 unchanged lines |
modified crates/hub/src/protocol.rs +8 −5
| … | 29 unchanged lines |
||
| 30 | 30 | /// A site token from the configuration. |
|
| 31 | 31 | Site, |
|
| 32 | 32 | Token(Box<Account>, Box<db::accounts::Token>), |
|
| 33 | - | /// A running OxygenFlow job: reads its own repository, nothing else (hub spec §13.2). |
|
| 33 | + | /// A running OxygenFlow job: reads its own repository and the subrepos |
|
| 34 | + | /// its state pins, nothing else (hub spec §13.2). |
|
| 34 | 35 | Job { |
|
| 35 | 36 | repo_id: i64, |
|
| 37 | + | also: Vec<i64>, |
|
| 36 | 38 | }, |
|
| 37 | 39 | } |
|
| 38 | 40 | ||
| … | 32 unchanged lines |
||
| 71 | 73 | let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) }; |
|
| 72 | 74 | let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?; |
|
| 73 | 75 | let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) }; |
|
| 74 | - | return Ok(Caller::Job { repo_id: run.repo_id }); |
|
| 76 | + | return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() }); |
|
| 75 | 77 | } |
|
| 76 | 78 | let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?; |
|
| 77 | 79 | let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) }; |
|
| … | 31 unchanged lines |
||
| 109 | 111 | let parsed = RepoName::parse(name).ok_or_else(not_found)?; |
|
| 110 | 112 | let row = self.row(&parsed)?; |
|
| 111 | 113 | let caller = self.caller(headers)?; |
|
| 112 | - | if let Caller::Job { repo_id } = caller { |
|
| 113 | - | // Only reads, only of the job's own repository. |
|
| 114 | + | if let Caller::Job { repo_id, also } = &caller { |
|
| 115 | + | // Only reads: of the job's own repository, and of the subrepos its |
|
| 116 | + | // state pins that its pusher could read when the job was claimed. |
|
| 114 | 117 | return match access { |
|
| 115 | - | oxy_server::Access::Read if repo_id == row.id => Ok(()), |
|
| 118 | + | oxy_server::Access::Read if *repo_id == row.id || also.contains(&row.id) => Ok(()), |
|
| 116 | 119 | oxy_server::Access::Read => Err(not_found()), |
|
| 117 | 120 | oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())), |
|
| 118 | 121 | }; |
|
| … | 135 unchanged lines |
modified crates/hub/tests/flow.rs +53 −0
| … | 340 unchanged lines |
||
| 341 | 341 | let (_, page) = call(&h.app, "GET", "/other/plain/flow", None, None).await; |
|
| 342 | 342 | assert!(page.contains("not enabled for this repository")); |
|
| 343 | 343 | } |
|
| 344 | + | ||
| 345 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 346 | + | async fn claims_carry_pinned_subrepos_and_the_job_token_reads_only_those() { |
|
| 347 | + | let h = hub().await; |
|
| 348 | + | for n in ["lib", "vault", "other", "app"] { |
|
| 349 | + | create(&h.app, "andy", n).await; |
|
| 350 | + | } |
|
| 351 | + | let push = |name: &str, file: &str| { |
|
| 352 | + | let work = tempfile::tempdir().unwrap(); |
|
| 353 | + | let c = client(&format!("{}/andy/{name}", h.base), work.path()); |
|
| 354 | + | write(work.path(), file, "x\n"); |
|
| 355 | + | c.snapshot("t").unwrap(); |
|
| 356 | + | c.commit("first", &[]).unwrap(); |
|
| 357 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 358 | + | let heads = c.bookmark_heads().unwrap(); |
|
| 359 | + | (work, heads) |
|
| 360 | + | }; |
|
| 361 | + | let (_l, lib_heads) = push("lib", "lib.rs"); |
|
| 362 | + | let (_v, _) = push("vault", "secret.rs"); |
|
| 363 | + | let (_o, _) = push("other", "o.rs"); |
|
| 364 | + | ||
| 365 | + | // The app pins lib and vault as subrepos and pushes as the site token. |
|
| 366 | + | let work = tempfile::tempdir().unwrap(); |
|
| 367 | + | let app = client(&format!("{}/andy/app", h.base), work.path()); |
|
| 368 | + | app.submodule_add(&format!("{}/andy/lib", h.base), "deps/lib", Some("main")).unwrap(); |
|
| 369 | + | app.submodule_add(&format!("{}/andy/vault", h.base), "deps/vault", Some("main")).unwrap(); |
|
| 370 | + | // Vault goes private after it was pinned: this pusher cannot read it. |
|
| 371 | + | sqlx::query("UPDATE repositories SET visibility = 'private' WHERE name = 'vault'").execute(&h.app.db).await.unwrap(); |
|
| 372 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 373 | + | write(work.path(), "Makefile", "all:\n"); |
|
| 374 | + | app.snapshot("t").unwrap(); |
|
| 375 | + | app.commit("app with deps", &[]).unwrap(); |
|
| 376 | + | assert!(app.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 377 | + | assert!(!runs_of(&h.app, "andy", "app").await.is_empty()); |
|
| 378 | + | ||
| 379 | + | let job = claim(&h.app).await.unwrap(); |
|
| 380 | + | let subs = job["subrepos"].as_array().cloned().unwrap_or_default(); |
|
| 381 | + | assert_eq!(subs.len(), 1, "vault is private and the pusher has no account: {subs:?}"); |
|
| 382 | + | assert_eq!(subs[0]["path"], "deps/lib"); |
|
| 383 | + | assert_eq!(subs[0]["repo"], "andy/lib"); |
|
| 384 | + | assert_eq!(subs[0]["heads"], serde_json::json!(lib_heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>())); |
|
| 385 | + | assert!(subs[0]["clone_url"].as_str().unwrap().ends_with("/andy/lib")); |
|
| 386 | + | ||
| 387 | + | // The job token reads its repository and the pinned lib, nothing else. |
|
| 388 | + | let token = job["token"].as_str().unwrap().to_string(); |
|
| 389 | + | let read = |name: &str| { |
|
| 390 | + | let opts = RemoteOptions { token: Some(token.clone()), ..Default::default() }; |
|
| 391 | + | open_remote(&format!("{}/andy/{name}", h.base), &opts).and_then(|r| r.refs()).is_ok() |
|
| 392 | + | }; |
|
| 393 | + | assert!(read("app") && read("lib")); |
|
| 394 | + | assert!(!read("vault"), "not pinned for this pusher"); |
|
| 395 | + | assert!(!read("other"), "public, but not the job's to read"); |
|
| 396 | + | } |