Artifacts between jobs: the claim names each input's blob, runners fetch inputs cd437c4f5f8d
Pinned at oxygenflow b14c128e. A claim fills inputs from the producing
job of the same run, which must have passed with a receipt (whose outputs
are exactly its kept artifacts), naming each artifact's blob; a job whose
producer kept nothing is failed with the reason. GET
/api/v1/flow/jobs/{id}/inputs/{index} serves the bytes to the runner
holding the job. Test: plan keeps tfplan, apply's claim names its blob,
the runner fetches those bytes; other indexes, other callers and finished
jobs are refused.
Refs andymccall/oxygenflow#58
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 03:33 UTC
- Signature
- ✔ signed by Claude Code
- Hash
cd437c4f5f8d939ad9b599f9be396233682fa7718dbe4374af92c0d86d121cf5- Depends on
- 1870e4fa9808 380996db84e5 42fa95540468 4ac01a732552 a36bab6b9ffc a3c8288b0141 c65b0f0a196f d23464c0ac60 fd3db644df22
- Depended on by
- 3b65065298b7 417184c73f4e
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 6 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/api/mod.rs +1 −0
| … | 57 unchanged lines |
||
| 58 | 58 | "/flow/jobs/{id}/artifacts/{name}", |
|
| 59 | 59 | post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
|
| 60 | 60 | ) |
|
| 61 | + | .route("/flow/jobs/{id}/inputs/{index}", get(crate::flow::input)) |
|
| 61 | 62 | .route( |
|
| 62 | 63 | "/flow/jobs/{id}/reports/{name}/files", |
|
| 63 | 64 | post(crate::flow::report_file).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
|
| … | 2476 unchanged lines |
modified crates/hub/src/flow.rs +72 −0
| … | 717 unchanged lines |
||
| 718 | 718 | // The site's, the owner's and the repository's variables (#102). |
|
| 719 | 719 | env: crate::variables::env_for_repo(&app.db, &row).await?, |
|
| 720 | 720 | secrets: vec![], |
|
| 721 | + | inputs: vec![], |
|
| 721 | 722 | }; |
|
| 722 | 723 | let mut claim = claim; |
|
| 723 | 724 | let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await; |
|
| … | 49 unchanged lines |
||
| 773 | 774 | fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?; |
|
| 774 | 775 | } |
|
| 775 | 776 | claim.subrepos = inputs; |
|
| 777 | + | // Artifacts of jobs it depends on (oxygenflow#58), with the blob each |
|
| 778 | + | // producer's receipt names; the runner checks what it fetches against it. |
|
| 779 | + | match artifact_inputs(&app, &job, &claim.job).await { |
|
| 780 | + | Ok(found) => claim.inputs = found, |
|
| 781 | + | Err(why) => { |
|
| 782 | + | let error = format!("inputs: {why}"); |
|
| 783 | + | fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
|
| 784 | + | .await?; |
|
| 785 | + | return Err(ApiError(StatusCode::CONFLICT, error)); |
|
| 786 | + | } |
|
| 787 | + | } |
|
| 776 | 788 | Ok(Json(claim).into_response()) |
|
| 789 | + | } |
|
| 790 | + | ||
| 791 | + | /// The artifacts a job takes (oxygenflow#58), each from a job of the same |
|
| 792 | + | /// run that passed with a receipt; a passing job's receipt names exactly the |
|
| 793 | + | /// artifacts it uploaded (checked at finish), so their blobs are attested. |
|
| 794 | + | async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> { |
|
| 795 | + | if spec.inputs.is_empty() { |
|
| 796 | + | return Ok(vec![]); |
|
| 797 | + | } |
|
| 798 | + | let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?; |
|
| 799 | + | let mut out = Vec::new(); |
|
| 800 | + | for i in &spec.inputs { |
|
| 801 | + | let Some(producer) = jobs.iter().find(|j| j.name == i.from) else { |
|
| 802 | + | return Err(format!("{:?} is not a job of this run", i.from)); |
|
| 803 | + | }; |
|
| 804 | + | if producer.status != "passed" || producer.receipt_hash.is_empty() { |
|
| 805 | + | return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact)); |
|
| 806 | + | } |
|
| 807 | + | let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?; |
|
| 808 | + | let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else { |
|
| 809 | + | return Err(format!("{} kept no artifact {}", i.from, i.artifact)); |
|
| 810 | + | }; |
|
| 811 | + | out.push(flow_core::api::ArtifactInput { |
|
| 812 | + | artifact: i.artifact.clone(), |
|
| 813 | + | from: i.from.clone(), |
|
| 814 | + | path: i.path.clone(), |
|
| 815 | + | blob: a.blob.clone(), |
|
| 816 | + | }); |
|
| 817 | + | } |
|
| 818 | + | Ok(out) |
|
| 819 | + | } |
|
| 820 | + | ||
| 821 | + | /// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index` |
|
| 822 | + | /// of a running job (oxygenflow#58), for the runner holding it. |
|
| 823 | + | pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> { |
|
| 824 | + | let runner = runner_auth(&app, &headers).await?; |
|
| 825 | + | let job = running_job(&app, id).await?; |
|
| 826 | + | // A registered runner reads only the inputs of a job it holds (a |
|
| 827 | + | // configured token's runner names itself when it claims). |
|
| 828 | + | if runner.source == "registered" && job.runner != runner.name { |
|
| 829 | + | return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into())); |
|
| 830 | + | } |
|
| 831 | + | let spec: flow_core::Job = |
|
| 832 | + | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
|
| 833 | + | let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?; |
|
| 834 | + | let Some(i) = found.get(index) else { |
|
| 835 | + | return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name))); |
|
| 836 | + | }; |
|
| 837 | + | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 838 | + | let row = |
|
| 839 | + | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
|
| 840 | + | let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?; |
|
| 841 | + | let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob) |
|
| 842 | + | .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?; |
|
| 843 | + | let bytes = app |
|
| 844 | + | .repos |
|
| 845 | + | .read(&repo, move |r, _| Ok(r.read_blob(&blob)?)) |
|
| 846 | + | .await |
|
| 847 | + | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?; |
|
| 848 | + | Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()) |
|
| 777 | 849 | } |
|
| 778 | 850 | ||
| 779 | 851 | /// BLAKE3 over what a claiming runner offers: tiers, images, architecture, |
|
| … | 1355 unchanged lines |
modified crates/hub/tests/flow.rs +61 −0
| … | 4191 unchanged lines |
||
| 4192 | 4192 | assert_eq!(st, StatusCode::OK, "{job}"); |
|
| 4193 | 4193 | assert_eq!(serde_json::from_str::<serde_json::Value>(&job).unwrap()["repo"], "bob/game"); |
|
| 4194 | 4194 | } |
|
| 4195 | + | ||
| 4196 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4197 | + | async fn a_job_takes_the_artifact_its_producer_kept() { |
|
| 4198 | + | let h = hub().await; |
|
| 4199 | + | create(&h.app, "andy", "demo").await; |
|
| 4200 | + | let url = format!("{}/andy/demo", h.base); |
|
| 4201 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4202 | + | let c = client(&url, work.path()); |
|
| 4203 | + | write( |
|
| 4204 | + | work.path(), |
|
| 4205 | + | ".flow/pipeline.kdl", |
|
| 4206 | + | &format!( |
|
| 4207 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"plan\" {{\n sandbox \"alpine\"\n exec \"plan\"\n output {{\n artifact \"tfplan\" {{\n path \"/src/out/tfplan\"\n }}\n }}\n}}\njob \"apply\" {{\n sandbox \"alpine\"\n depends-on \"plan\"\n input {{\n artifact \"tfplan\" from \"plan\"\n path \"/src/tfplan\"\n }}\n exec \"apply\"\n}}\n" |
|
| 4208 | + | ), |
|
| 4209 | + | ); |
|
| 4210 | + | c.snapshot("test").unwrap(); |
|
| 4211 | + | c.commit("Plan, then apply that plan", &[]).unwrap(); |
|
| 4212 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4213 | + | ||
| 4214 | + | // The plan job runs and keeps its plan, named by its receipt. |
|
| 4215 | + | let runs = runs_of(&h.app, "andy", "demo").await; |
|
| 4216 | + | assert_eq!(runs.len(), 1, "a run"); |
|
| 4217 | + | let jobs = db::flow::jobs(&h.app.db, runs[0].id).await.unwrap(); |
|
| 4218 | + | assert!(!jobs.is_empty(), "jobs: {:?}", runs[0]); |
|
| 4219 | + | let p = claim(&h.app) |
|
| 4220 | + | .await |
|
| 4221 | + | .unwrap_or_else(|| panic!("nothing to claim: {:?}", jobs.iter().map(|j| (&j.name, &j.status, &j.error)).collect::<Vec<_>>())); |
|
| 4222 | + | assert_eq!(p["job"]["name"], "plan"); |
|
| 4223 | + | let plan_id = p["job_id"].as_i64().unwrap(); |
|
| 4224 | + | let plan = b"the reviewed plan".to_vec(); |
|
| 4225 | + | let (st, text) = upload(&h.app, plan_id, "tfplan", &plan).await; |
|
| 4226 | + | assert_eq!(st, StatusCode::OK, "{text}"); |
|
| 4227 | + | let blob = oxy_core::repo::Repo::blob_ref(&plan).to_hex(); |
|
| 4228 | + | let mut r = receipt("plan", p["pipeline_hash"].as_str().unwrap(), 0); |
|
| 4229 | + | r["outputs"] = serde_json::json!([blob]); |
|
| 4230 | + | assert_eq!( |
|
| 4231 | + | finish(&h.app, plan_id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": r })).await, |
|
| 4232 | + | StatusCode::NO_CONTENT |
|
| 4233 | + | ); |
|
| 4234 | + | ||
| 4235 | + | // The apply job is told what to take and the blob to expect, and the |
|
| 4236 | + | // runner fetches exactly those bytes. |
|
| 4237 | + | let a = claim(&h.app).await.unwrap(); |
|
| 4238 | + | assert_eq!(a["job"]["name"], "apply"); |
|
| 4239 | + | let apply_id = a["job_id"].as_i64().unwrap(); |
|
| 4240 | + | assert_eq!( |
|
| 4241 | + | a["inputs"], |
|
| 4242 | + | serde_json::json!([{ "artifact": "tfplan", "from": "plan", "path": "/src/tfplan", "blob": blob }]), |
|
| 4243 | + | "the claim names the producer's blob" |
|
| 4244 | + | ); |
|
| 4245 | + | let req = Request::get(format!("/api/v1/flow/jobs/{apply_id}/inputs/0")).header(header::AUTHORIZATION, "Bearer runner-secret"); |
|
| 4246 | + | let res = oxygenhub::router(h.app.clone()).oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); |
|
| 4247 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 4248 | + | assert_eq!(res.into_body().collect().await.unwrap().to_bytes().to_vec(), plan); |
|
| 4249 | + | let (st, _) = call(&h.app, "GET", &format!("/api/v1/flow/jobs/{apply_id}/inputs/1"), Some("runner-secret"), None).await; |
|
| 4250 | + | assert_eq!(st, StatusCode::NOT_FOUND, "no second input"); |
|
| 4251 | + | let (st, _) = call(&h.app, "GET", &format!("/api/v1/flow/jobs/{apply_id}/inputs/0"), None, None).await; |
|
| 4252 | + | assert_eq!(st, StatusCode::UNAUTHORIZED, "runners only"); |
|
| 4253 | + | let (st, _) = call(&h.app, "GET", &format!("/api/v1/flow/jobs/{plan_id}/inputs/0"), Some("runner-secret"), None).await; |
|
| 4254 | + | assert_eq!(st, StatusCode::CONFLICT, "only a running job's"); |
|
| 4255 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.