Another repository's pass is taken only on its signed receipt, re-verified from that repository's store and its key not revoked; only passes that ran there (closes #296's C2, refs #296) 06b756358f7e
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2d7fba081de4
- When
- · 2026-10-27 23:24 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
06b756358f7e15fa0441184178b46de52dd21e161d43a0dbe337234f0d96db37- Depends on
- 42cd46c9b09e 42fa95540468 5588bb3631d2 91c306aa4706 a7e6e8e6094c b1860d6090ec b5668108487f
- Depended on by
- 4dfa4599b3f7
- In bookmarks
- main
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 13 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Another repository's pass is taken only on its signed receipt (#296).** A pass from upstream or from any other repository on the hub now stands only if its runner's signed receipt was kept (the hub checks it against the runner's bound key when the job finishes) and, read again from that repository's store, still verifies and its key is not revoked; otherwise the job runs here. Only a pass that ran in the repository offering it is taken, so the receipt is where it is checked. |
|
| 8 | + | ||
| 7 | 9 | <<<<<<< oxy conflict 1 of 1, side 1 of 2: change 681d2bd9 "Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5)" |
|
| 8 | 10 | - **Approval gates and expedite for clients (oxygenhub-android#5).** A run's jobs over the API say `approval` (`waiting` while one waits for a person, `approved` or `rejected` after), and `POST /api/v1/repos/OWNER/NAME/flow/runs/N/expedite {"reason": …}` makes a run urgent as the run page's Expedite does (a maintainer, with a reason, within the owner's cap of urgent runs); the page and the API share it. |
|
| 9 | 11 | ||
| … | 389 unchanged lines |
modified crates/hub/src/db/flow.rs +19 −2
| … | 280 unchanged lines |
||
| 281 | 281 | spec: &str, |
|
| 282 | 282 | sandbox: &str, |
|
| 283 | 283 | ) -> sqlx::Result<Option<Reused>> { |
|
| 284 | + | passed_in(pool, repo_id, state, name, spec, sandbox, false).await |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | /// As [`passed_on_state`]; with `ran_here`, only a job that ran in that |
|
| 288 | + | /// repository, not one it reused: its signed receipt is in that |
|
| 289 | + | /// repository's store, where another repository's reuse checks it (#296). |
|
| 290 | + | async fn passed_in( |
|
| 291 | + | pool: &Pool, |
|
| 292 | + | repo_id: i64, |
|
| 293 | + | state: &str, |
|
| 294 | + | name: &str, |
|
| 295 | + | spec: &str, |
|
| 296 | + | sandbox: &str, |
|
| 297 | + | ran_here: bool, |
|
| 298 | + | ) -> sqlx::Result<Option<Reused>> { |
|
| 284 | 299 | let row: Option<(i64, String, String, String, String, String)> = sqlx::query_as( |
|
| 285 | 300 | "SELECT r.number, j.receipt, j.receipt_hash, j.receipt_attest, j.receipt_signer, j.receipt_note \ |
|
| 286 | 301 | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \ |
|
| 287 | 302 | WHERE r.repo_id = $1 AND r.state = $2 AND j.name = $3 AND j.spec = $4 AND j.sandbox = $5 AND j.status = 'passed' \ |
|
| 303 | + | AND (NOT $6 OR j.error NOT LIKE 'reused from %') \ |
|
| 288 | 304 | ORDER BY j.id DESC LIMIT 1", |
|
| 289 | 305 | ) |
|
| 290 | 306 | .bind(repo_id) |
|
| … | 1 unchanged lines |
||
| 292 | 308 | .bind(name) |
|
| 293 | 309 | .bind(spec) |
|
| 294 | 310 | .bind(sandbox) |
|
| 311 | + | .bind(ran_here) |
|
| 295 | 312 | .fetch_optional(pool) |
|
| 296 | 313 | .await?; |
|
| 297 | 314 | Ok(row.map(|(run_number, receipt, receipt_hash, receipt_attest, receipt_signer, receipt_note)| Reused { |
|
| … | 31 unchanged lines |
||
| 329 | 346 | if !(up.visibility == "public" || up.owner == repo.owner) { |
|
| 330 | 347 | continue; |
|
| 331 | 348 | } |
|
| 332 | - | if let Some(mut r) = passed_on_state(pool, up.id, state, name, spec, sandbox).await? { |
|
| 349 | + | if let Some(mut r) = passed_in(pool, up.id, state, name, spec, sandbox, true).await? { |
|
| 333 | 350 | r.repo = Some(format!("{}/{}", up.owner, up.name)); |
|
| 334 | 351 | return Ok(Some(r)); |
|
| 335 | 352 | } |
|
| … | 29 unchanged lines |
||
| 365 | 382 | "SELECT o.owner, o.name, r.number, j.receipt, j.receipt_hash, j.receipt_attest, j.receipt_signer, j.receipt_note \ |
|
| 366 | 383 | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories o ON o.id = r.repo_id \ |
|
| 367 | 384 | WHERE r.state = $1 AND j.name = $2 AND j.spec = $3 AND j.sandbox = $4 AND j.status = 'passed' \ |
|
| 368 | - | AND o.id <> $5 AND (o.visibility = 'public' OR o.owner = $6) \ |
|
| 385 | + | AND o.id <> $5 AND (o.visibility = 'public' OR o.owner = $6) AND j.error NOT LIKE 'reused from %' \ |
|
| 369 | 386 | ORDER BY j.id DESC LIMIT 1", |
|
| 370 | 387 | ) |
|
| 371 | 388 | .bind(state) |
|
| … | 1276 unchanged lines |
modified crates/hub/src/flow.rs +28 −0
| … | 847 unchanged lines |
||
| 848 | 848 | j.reused = |
|
| 849 | 849 | fdb::passed_elsewhere(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?; |
|
| 850 | 850 | } |
|
| 851 | + | // Another repository's pass is taken only on its signed |
|
| 852 | + | // receipt, checked again now: otherwise it runs here. |
|
| 853 | + | if let Some((from, attest)) = j.reused.as_ref().and_then(|r| Some((r.repo.clone()?, r.receipt_attest.clone()))) { |
|
| 854 | + | if let Err(why) = receipt_holds(app, &from, &attest).await { |
|
| 855 | + | tracing::info!("flow: not reusing {from}'s pass of {} on {state}: {why}", j.name); |
|
| 856 | + | j.reused = None; |
|
| 857 | + | } |
|
| 858 | + | } |
|
| 851 | 859 | } |
|
| 852 | 860 | } |
|
| 853 | 861 | let hash = hex::encode(p.hash()); |
|
| … | 1737 unchanged lines |
||
| 2591 | 2599 | tracing::warn!("flow: placing after an expedite: {e}"); |
|
| 2592 | 2600 | } |
|
| 2593 | 2601 | app.bus.live(&crate::events::topic::run(run.id), "jobs"); |
|
| 2602 | + | } |
|
| 2603 | + | Ok(()) |
|
| 2604 | + | } |
|
| 2605 | + | ||
| 2606 | + | /// Whether another repository's pass may stand here (#296): its runner's |
|
| 2607 | + | /// signed receipt was kept (the hub checked it against the runner's bound |
|
| 2608 | + | /// key when the job finished), and read again from that repository's store |
|
| 2609 | + | /// it still verifies and its key is not revoked. |
|
| 2610 | + | pub async fn receipt_holds(app: &Shared, from: &str, attest: &str) -> Result<(), String> { |
|
| 2611 | + | if attest.is_empty() { |
|
| 2612 | + | return Err("no signed receipt was kept".into()); |
|
| 2613 | + | } |
|
| 2614 | + | let repo = crate::repos::RepoName::parse(from).ok_or("not a repository name")?; |
|
| 2615 | + | let at = oxy_core::hash::ObjectRef::from_hex(attest).ok_or("not a receipt's hash")?; |
|
| 2616 | + | let info = app.repos.read(&repo, move |r, _| Ok(r.receipt(&at)?)).await.map_err(|e| format!("the receipt: {e}"))?; |
|
| 2617 | + | if let Some(problem) = info.problem { |
|
| 2618 | + | return Err(format!("its receipt does not verify: {problem}")); |
|
| 2619 | + | } |
|
| 2620 | + | if info.revoked { |
|
| 2621 | + | return Err("its runner's key is revoked".into()); |
|
| 2594 | 2622 | } |
|
| 2595 | 2623 | Ok(()) |
|
| 2596 | 2624 | } |
|
| … | 511 unchanged lines |
modified crates/hub/tests/flow.rs +52 −6
| … | 2017 unchanged lines |
||
| 2018 | 2018 | c.commit("Start", &[]).unwrap(); |
|
| 2019 | 2019 | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2020 | 2020 | let first = runs_of(&h.app, "andy", "game").await.remove(0); |
|
| 2021 | - | sqlx::query("UPDATE flow_jobs SET status = 'passed', exit_code = 0, receipt_hash = 'r-' || name WHERE run_id = $1") |
|
| 2022 | - | .bind(first.id) |
|
| 2023 | - | .execute(&h.app.db) |
|
| 2024 | - | .await |
|
| 2025 | - | .unwrap(); |
|
| 2021 | + | // Upstream's jobs pass on a runner, each with its signed receipt, which |
|
| 2022 | + | // the hub checks against the runner's key and keeps (#296's C2). |
|
| 2023 | + | { |
|
| 2024 | + | use oxy_core::hash::{hash_object, ChangeHash, ObjectType}; |
|
| 2025 | + | use oxy_core::identity::SecretKey; |
|
| 2026 | + | use oxy_core::objects::StateObject; |
|
| 2027 | + | use oxy_core::receipt::SignedReceipt; |
|
| 2028 | + | let heads: Vec<ChangeHash> = first.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
|
| 2029 | + | let state = hash_object(ObjectType::State, &StateObject::new(heads).encode()); |
|
| 2030 | + | let repo_id = h.app.repos.open(&RepoName::parse("andy/game").unwrap()).unwrap().id().unwrap(); |
|
| 2031 | + | let sk = SecretKey::generate(); |
|
| 2032 | + | let runner = sk.identity("test-1 (OxygenFlow runner)", "", 1); |
|
| 2033 | + | let run_url = format!("{}/andy/game/flow/{}", h.base, first.number); |
|
| 2034 | + | while let Some(j) = claim(&h.app).await { |
|
| 2035 | + | let r = receipt(j["job"]["name"].as_str().unwrap(), j["pipeline_hash"].as_str().unwrap(), 0); |
|
| 2036 | + | let parsed: flow_core::Receipt = serde_json::from_value(r.clone()).unwrap(); |
|
| 2037 | + | let signed = hex::encode(SignedReceipt::sign(parsed.to_oxy(), repo_id, state, &run_url, &runner, &sk).encode()); |
|
| 2038 | + | let body = serde_json::json!({ "exit_code": 0, "duration_ms": 5, "receipt": r, "signed_receipt": signed }); |
|
| 2039 | + | assert_eq!(finish(&h.app, j["job_id"].as_i64().unwrap(), body).await, StatusCode::NO_CONTENT); |
|
| 2040 | + | } |
|
| 2041 | + | } |
|
| 2042 | + | let up_jobs = db::flow::jobs(&h.app.db, first.id).await.unwrap(); |
|
| 2043 | + | assert!(up_jobs.iter().all(|j| j.status == "passed" && j.receipt_attest.len() == 64), "{up_jobs:?}"); |
|
| 2026 | 2044 | // The same changes pushed to the fork: the same state. |
|
| 2027 | 2045 | c.config.set("remote.origin.url", &format!("{}/bob/game", h.base)).unwrap(); |
|
| 2028 | 2046 | c.save_config().unwrap(); |
|
| … | 3 unchanged lines |
||
| 2032 | 2050 | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 2033 | 2051 | for j in &jobs { |
|
| 2034 | 2052 | assert_eq!((j.status.as_str(), j.reused_from(), j.reused_repo()), ("passed", Some(first.number), Some("andy/game")), "{j:?}"); |
|
| 2035 | - | assert_eq!(j.receipt_hash, format!("r-{}", j.name)); |
|
| 2053 | + | let from = up_jobs.iter().find(|u| u.name == j.name).unwrap(); |
|
| 2054 | + | assert_eq!(j.receipt_attest, from.receipt_attest, "upstream's signed receipt stands for it"); |
|
| 2036 | 2055 | } |
|
| 2037 | 2056 | let (_, page) = call(&h.app, "GET", &format!("/bob/game/flow/{}", run.number), None, None).await; |
|
| 2038 | 2057 | assert!(page.contains(&format!("href=\"/andy/game/flow/{}\">andy/game run #{}</a>", first.number, first.number)), "{page}"); |
|
| 2039 | 2058 | let checks = db::hooks::checks_for_state(&h.app.db, fork.id, &run.state).await.unwrap(); |
|
| 2040 | 2059 | assert!(checks.iter().any(|c| c.summary.contains("passed in andy/game run #")), "{checks:?}"); |
|
| 2060 | + | ||
| 2061 | + | // A pass whose receipt does not hold (here, one the store never kept) |
|
| 2062 | + | // is not taken: that job runs in the fork; the others are reused. |
|
| 2063 | + | let lint = up_jobs.iter().find(|j| j.name == "lint").unwrap(); |
|
| 2064 | + | sqlx::query("UPDATE flow_jobs SET receipt_attest = $1 WHERE id = $2") |
|
| 2065 | + | .bind("ab".repeat(32)) |
|
| 2066 | + | .bind(lint.id) |
|
| 2067 | + | .execute(&h.app.db) |
|
| 2068 | + | .await |
|
| 2069 | + | .unwrap(); |
|
| 2070 | + | create(&h.app, "bob", "game2").await; |
|
| 2071 | + | let fork2 = db::repos::get(&h.app.db, "bob", "game2").await.unwrap().unwrap(); |
|
| 2072 | + | db::repos::set_forked_from(&h.app.db, fork2.id, up.id).await.unwrap(); |
|
| 2073 | + | sqlx::query("UPDATE repositories SET flow = 'on' WHERE id = $1").bind(fork2.id).execute(&h.app.db).await.unwrap(); |
|
| 2074 | + | c.config.set("remote.origin.url", &format!("{}/bob/game2", h.base)).unwrap(); |
|
| 2075 | + | c.save_config().unwrap(); |
|
| 2076 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2077 | + | let jobs2 = db::flow::jobs(&h.app.db, runs_of(&h.app, "bob", "game2").await.remove(0).id).await.unwrap(); |
|
| 2078 | + | let status = |name: &str| jobs2.iter().find(|j| j.name == name).map(|j| (j.status.clone(), j.reused_repo().map(String::from))).unwrap(); |
|
| 2079 | + | assert_eq!(status("lint"), ("queued".to_string(), None), "{jobs2:?}"); |
|
| 2080 | + | assert_eq!(status("build"), ("passed".to_string(), Some("andy/game".to_string())), "{jobs2:?}"); |
|
| 2081 | + | sqlx::query("UPDATE flow_jobs SET receipt_attest = $1 WHERE id = $2") |
|
| 2082 | + | .bind(&lint.receipt_attest) |
|
| 2083 | + | .bind(lint.id) |
|
| 2084 | + | .execute(&h.app.db) |
|
| 2085 | + | .await |
|
| 2086 | + | .unwrap(); |
|
| 2041 | 2087 | ||
| 2042 | 2088 | // Any repository on the hub holding the same state, not only forks: |
|
| 2043 | 2089 | // carol's, which no one forked, starts green from a public pass. |
|
| … | 5053 unchanged lines |