OXYGENHUB

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.