Flow: a pipeline pinning an image nobody holds fails a check 1c8276b90314
A run whose jobs pin an image held by neither the hub nor a runner (when every runner reports what it holds) sets a failing pipeline/images check on its state, naming the images and saying to send them with flow rootfs push, so a proposal pinning a new image shows it before it lands instead of its runs queueing for good; a later run of the state turns the check to passed once every image is held. closes #137
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-12 00:49 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1c8276b903141bd49f9b744e8c2e41d7e5c4a58c3208a0866714da894b79528a- Depends on
- 20b8fe85888b 42fa95540468 501d8b268dd4 92a797b20912 93be9ce4653e a0d4bbfbedd1 cc19697f8871
- Depended on by
- 5588bb3631d2 33d98147511b a46d15e72101
- In bookmarks
- api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 6 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/flow.rs +62 −0
| … | 384 unchanged lines |
||
| 385 | 385 | for j in &jobs { |
|
| 386 | 386 | report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await; |
|
| 387 | 387 | } |
|
| 388 | + | images_check(app, row, state, run.number, &jobs).await; |
|
| 388 | 389 | } |
|
| 389 | 390 | id |
|
| 390 | 391 | } |
|
| … | 2 unchanged lines |
||
| 393 | 394 | Ok(Some(run.number)) |
|
| 394 | 395 | } |
|
| 395 | 396 | ||
| 397 | + | /// The `pipeline/images` check (#137): set failing when an image the run's |
|
| 398 | + | /// jobs pin is held by neither the hub nor a runner, so a proposal pinning |
|
| 399 | + | /// one says so before it lands rather than its runs queueing for good; |
|
| 400 | + | /// passed again by a later run of the state once every image is held. |
|
| 401 | + | async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) { |
|
| 402 | + | let mut pinned: Vec<String> = jobs |
|
| 403 | + | .iter() |
|
| 404 | + | .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok()) |
|
| 405 | + | .filter_map(|v| v["rootfs"].as_str().map(String::from)) |
|
| 406 | + | .filter(|r| !r.is_empty()) |
|
| 407 | + | .collect(); |
|
| 408 | + | pinned.sort(); |
|
| 409 | + | pinned.dedup(); |
|
| 410 | + | if pinned.is_empty() { |
|
| 411 | + | return; |
|
| 412 | + | } |
|
| 413 | + | let mut held = crate::images::held(&app.db).await.unwrap_or_default(); |
|
| 414 | + | // Nobody can be said to lack an image unless some runner is known and |
|
| 415 | + | // every one reports what it holds. |
|
| 416 | + | let runners: Vec<db::runners::Runner> = |
|
| 417 | + | db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect(); |
|
| 418 | + | if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) { |
|
| 419 | + | return; |
|
| 420 | + | } |
|
| 421 | + | for r in &runners { |
|
| 422 | + | held.extend(r.image_list().unwrap_or_default()); |
|
| 423 | + | } |
|
| 424 | + | let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect(); |
|
| 425 | + | // Only a missing image makes the check; one that was failing on this |
|
| 426 | + | // state turns to passed once its images are held. |
|
| 427 | + | let failing_before = db::hooks::checks_for_state(&app.db, row.id, state) |
|
| 428 | + | .await |
|
| 429 | + | .unwrap_or_default() |
|
| 430 | + | .iter() |
|
| 431 | + | .any(|c| c.name == "pipeline/images" && c.status != "passed"); |
|
| 432 | + | if missing.is_empty() && !failing_before { |
|
| 433 | + | return; |
|
| 434 | + | } |
|
| 435 | + | let (status, summary) = if missing.is_empty() { |
|
| 436 | + | ( |
|
| 437 | + | "passed", |
|
| 438 | + | format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }), |
|
| 439 | + | ) |
|
| 440 | + | } else { |
|
| 441 | + | let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect(); |
|
| 442 | + | ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", "))) |
|
| 443 | + | }; |
|
| 444 | + | let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name); |
|
| 445 | + | if let Ok(c) = db::hooks::upsert_check( |
|
| 446 | + | &app.db, |
|
| 447 | + | row.id, |
|
| 448 | + | db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" }, |
|
| 449 | + | ) |
|
| 450 | + | .await |
|
| 451 | + | { |
|
| 452 | + | let api = crate::api::check_of(&c); |
|
| 453 | + | app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
|
| 454 | + | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
|
| 455 | + | } |
|
| 456 | + | } |
|
| 457 | + | ||
| 396 | 458 | /// Record a job's check; it links to the run's page by the run's number. |
|
| 397 | 459 | async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) { |
|
| 398 | 460 | let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name); |
|
| … | 1456 unchanged lines |
modified crates/hub/tests/flow.rs +38 −0
| … | 2692 unchanged lines |
||
| 2693 | 2693 | c.commit("Build on the image", &[]).unwrap(); |
|
| 2694 | 2694 | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2695 | 2695 | runs_of(&h.app, "andy", "demo").await; |
|
| 2696 | + | // #137: the run's images are held (by the hub), so no images check. |
|
| 2697 | + | let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 2698 | + | let run1 = db::flow::run(&h.app.db, row.id, 1).await.unwrap().unwrap(); |
|
| 2699 | + | let checks = checks_when(&h.app, row.id, &run1.state, 1).await; |
|
| 2700 | + | assert!(checks.iter().all(|c| c.name != "pipeline/images"), "{checks:?}"); |
|
| 2696 | 2701 | let ask = |fetches: bool| format!(r#"{{"runner":"test-1","runtimes":["microvm"],"images":[],"fetches_images":{fetches}}}"#); |
|
| 2697 | 2702 | let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(false))).await; |
|
| 2698 | 2703 | assert_eq!(st, StatusCode::NO_CONTENT, "it lacks the image and does not fetch"); |
|
| … | 220 unchanged lines |
||
| 2919 | 2924 | assert_eq!(oxygenhub::reports::expire(&h.app.db).await.unwrap(), 0, "once"); |
|
| 2920 | 2925 | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/reports/perf?id={}", feature[0]), None, None).await; |
|
| 2921 | 2926 | assert!(page.contains("files have expired"), "the page says so"); |
|
| 2927 | + | } |
|
| 2928 | + | ||
| 2929 | + | /// #137: a run pinning an image neither the hub nor any runner holds sets a |
|
| 2930 | + | /// failing `pipeline/images` check saying how to send it; once it is held, |
|
| 2931 | + | /// a run of the same state turns the check to passed. |
|
| 2932 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2933 | + | async fn a_pipeline_pinning_an_image_nobody_holds_fails_a_check() { |
|
| 2934 | + | let h = hub().await; |
|
| 2935 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 2936 | + | // The one runner reports holding no images. |
|
| 2937 | + | let none = r#"{"runner":"test-1","runtimes":["microvm"],"images":[]}"#; |
|
| 2938 | + | let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(none.into())).await; |
|
| 2939 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 2940 | + | create(&h.app, "andy", "demo").await; |
|
| 2941 | + | let url = format!("{}/andy/demo", h.base); |
|
| 2942 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2943 | + | let c = client(&url, work.path()); |
|
| 2944 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 2945 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 2946 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 2947 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2948 | + | let run = runs_of(&h.app, "andy", "demo").await.remove(0); |
|
| 2949 | + | let checks = checks_when(&h.app, run.repo_id, &run.state, 4).await; |
|
| 2950 | + | let check = checks.iter().find(|c| c.name == "pipeline/images").expect("an images check"); |
|
| 2951 | + | assert!(check.status == "failed" && check.summary.contains(&ROOTFS[..12]) && check.summary.contains("flow rootfs push"), "{check:?}"); |
|
| 2952 | + | // Once a runner holds it, a run of the state passes the check. |
|
| 2953 | + | let holds = format!(r#"{{"runner":"test-1","runtimes":["microvm"],"images":["{ROOTFS}"]}}"#); |
|
| 2954 | + | call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(holds)).await; |
|
| 2955 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 2956 | + | assert_eq!(st, StatusCode::CREATED); |
|
| 2957 | + | let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap(); |
|
| 2958 | + | let check = checks.iter().find(|c| c.name == "pipeline/images").unwrap(); |
|
| 2959 | + | assert_eq!(check.status, "passed", "{check:?}"); |
|
| 2922 | 2960 | } |
modified docs/spec.md +1 −1
| … | 514 unchanged lines |
||
| 515 | 515 | - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`. |
|
| 516 | 516 | ||
| 517 | 517 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 518 | - | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). |
|
| 518 | + | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). |
|
| 519 | 519 | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 520 | 520 | - **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value. |
|
| 521 | 521 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| … | 231 unchanged lines |