OXYGENHUB

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 +
}