Admin › Runners › Images: what images are where 6ca0b971ff07
A third tab on Admin › Runners lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first: size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it. An image held nowhere is marked, with how to send it. closes #146
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-12 19:07 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6ca0b971ff07e7427ccf4652b7dcb573c03f4027399d3b6d244b83b47ec48677- Depends on
- 16626c0d69c0 19bb31ff1f5c 42fa95540468 5dead1f4f26d 6adcd68b90b1 92a797b20912 93be9ce4653e a46d15e72101 b6b7e4cf6b24 cc19697f8871 d23464c0ac60 e8e7158bbc73
- Depended on by
- 5d66f27cebe2 951aed50c2f5 8501fd5815b9 737de0fac722 e97500b70f9a 16ee5d19d220 107eaba52c9d c8684d44d549 3b1bc27fe805 5d90e0e045e2 6246d1e51f4f 757195ad7a5d f677d6270298
- In bookmarks
- api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready 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
- 14 atoms, 1 tree operation, 11 files
Browse the tree at this change
added templates/admin_images.html +24 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Images · Administration{% endblock %} |
|
| 3 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | + | {% block content %} |
|
| 5 | + | <div class="head"><h1>Runners <small>{{ images.len()|plural("image", "images") }} · {{ on_hub }} on the hub · <a href="/admin">Administration</a></small></h1></div> |
|
| 6 | + | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 7 | + | <p class="small mute">Every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first. {% if swarm.is_empty() %}No runner shares images peer to peer yet: give one a <code>[runner.swarm]</code> section.{% else %}Sharing images peer to peer: {% for s in swarm %}{{ s }}{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %} Send an image to the hub with <code>flow rootfs push HASH</code>.</p> |
|
| 8 | + | {% if images.is_empty() %} |
|
| 9 | + | <p class="mute">No images yet.</p> |
|
| 10 | + | {% else %} |
|
| 11 | + | <div class="table"><table class="images"> |
|
| 12 | + | <tr><th>Image</th><th>Size</th><th>Where</th><th>Used by</th><th>Sent</th></tr> |
|
| 13 | + | {% for i in images %} |
|
| 14 | + | <tr{% if i.stranded() %} class="stranded"{% endif %}> |
|
| 15 | + | <td><code class="chip" title="{{ i.hash }}">{{ i.hash|short }}</code>{% if !i.arch.is_empty() %} <span class="tag">{{ i.arch }}</span>{% endif %}</td> |
|
| 16 | + | <td class="small">{% if let Some(s) = i.size %}{{ s|bytes }}{% else %}<span class="mute" title="Only the hub's copy is measured">—</span>{% endif %}</td> |
|
| 17 | + | <td>{% if i.stranded() %}<span class="tag bad" title="A job pinning it will wait until it is sent: flow rootfs push">nowhere</span>{% endif %}{% for p in i.places %}<span class="lbl img-{{ p.kind }}" title="{{ p.title }}">{% if p.kind == "fetching" %}⇣ {% endif %}{{ p.name }}</span>{% endfor %}</td> |
|
| 18 | + | <td class="small">{% for (repo, jobs, last) in i.uses %}<div><a href="/{{ repo }}/runs">{{ repo }}</a> <span class="mute">{{ jobs|plural("job", "jobs") }}, <time title="{{ last|when_i }}">{{ last|ago_i }}</time></span></div>{% endfor %}{% if i.uses.is_empty() %}<span class="mute">no recent jobs</span>{% endif %}</td> |
|
| 19 | + | <td class="small">{% if let Some((who, when)) = i.sent %}{{ who }} <time class="mute" title="{{ when|when_i }}">{{ when|ago_i }}</time>{% else %}<span class="mute">not on the hub</span>{% endif %}</td> |
|
| 20 | + | </tr> |
|
| 21 | + | {% endfor %} |
|
| 22 | + | </table></div> |
|
| 23 | + | {% endif %} |
|
| 24 | + | {% endblock %} |
modified crates/hub/src/images.rs +45 −0
| … | 113 unchanged lines |
||
| 114 | 114 | pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> { |
|
| 115 | 115 | Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect()) |
|
| 116 | 116 | } |
|
| 117 | + | ||
| 118 | + | /// An image this hub keeps (`jet_images`). |
|
| 119 | + | #[derive(sqlx::FromRow)] |
|
| 120 | + | pub struct Kept { |
|
| 121 | + | pub hash: String, |
|
| 122 | + | pub size: i64, |
|
| 123 | + | pub arch: String, |
|
| 124 | + | pub chunks: i64, |
|
| 125 | + | pub uploader: String, |
|
| 126 | + | pub created_ms: i64, |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | pub async fn kept(pool: &Pool) -> sqlx::Result<Vec<Kept>> { |
|
| 130 | + | sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms FROM jet_images").fetch_all(pool).await |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | /// One repository's use of one image: its jobs since `since_ms`, and when |
|
| 134 | + | /// it last ran one. |
|
| 135 | + | #[derive(sqlx::FromRow)] |
|
| 136 | + | pub struct Use { |
|
| 137 | + | pub image: String, |
|
| 138 | + | pub owner: String, |
|
| 139 | + | pub name: String, |
|
| 140 | + | pub jobs: i64, |
|
| 141 | + | pub last_ms: i64, |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | /// What each repository's jobs ran on since `since_ms`, newest first. |
|
| 145 | + | pub async fn uses(pool: &Pool, since_ms: i64) -> sqlx::Result<Vec<Use>> { |
|
| 146 | + | sqlx::query_as( |
|
| 147 | + | "SELECT lower(json_extract(j.sandbox, '$.rootfs')) AS image, p.owner, p.name, COUNT(*) AS jobs, |
|
| 148 | + | MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms |
|
| 149 | + | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id |
|
| 150 | + | WHERE r.created_ms >= ? AND COALESCE(json_extract(j.sandbox, '$.rootfs'), '') != '' |
|
| 151 | + | GROUP BY image, p.id ORDER BY last_ms DESC", |
|
| 152 | + | ) |
|
| 153 | + | .bind(since_ms) |
|
| 154 | + | .fetch_all(pool) |
|
| 155 | + | .await |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | /// The architecture runners reported for each image (`flow_images`). |
|
| 159 | + | pub async fn reported_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> { |
|
| 160 | + | Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect()) |
|
| 161 | + | } |
modified crates/hub/src/swarm.rs +13 −0
| … | 75 unchanged lines |
||
| 76 | 76 | } |
|
| 77 | 77 | } |
|
| 78 | 78 | ||
| 79 | + | /// Who is fetching which image now, by image. |
|
| 80 | + | pub fn fetching_now(&self) -> HashMap<String, Vec<i64>> { |
|
| 81 | + | let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 82 | + | let now = db::now_ms(); |
|
| 83 | + | fetching |
|
| 84 | + | .iter() |
|
| 85 | + | .map(|(image, who)| { |
|
| 86 | + | (image.clone(), who.iter().filter(|(_, seen)| now - **seen < FETCHING_FRESH_MS).map(|(r, _)| *r).collect::<Vec<_>>()) |
|
| 87 | + | }) |
|
| 88 | + | .filter(|(_, who)| !who.is_empty()) |
|
| 89 | + | .collect() |
|
| 90 | + | } |
|
| 91 | + | ||
| 79 | 92 | fn fetchers(&self, image: &str) -> Vec<i64> { |
|
| 80 | 93 | let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 81 | 94 | fetching.get(image).map(|who| who.keys().copied().collect()).unwrap_or_default() |
|
| … | 75 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 114 unchanged lines |
||
| 115 | 115 | .route("/admin/storage", get(admin::storage).post(admin::maintain)) |
|
| 116 | 116 | .route("/admin/runners", get(runners::list)) |
|
| 117 | 117 | .route("/admin/runners/settings", get(runners::settings).post(runners::save_settings)) |
|
| 118 | + | .route("/admin/runners/images", get(runners::images)) |
|
| 118 | 119 | .route("/admin/runners/token", post(runners::new_token)) |
|
| 119 | 120 | .route("/admin/runners/{id}", get(runners::show)) |
|
| 120 | 121 | .route("/admin/runners/{id}/state", post(runners::set_state)) |
|
| … | 615 unchanged lines |
modified crates/hub/src/web/runners.rs +113 −0
| … | 413 unchanged lines |
||
| 414 | 414 | .ok(); |
|
| 415 | 415 | Ok(settings_page(&app, shell, Some("Saved. Jobs claimed from now on run under these limits.".into()), None).await) |
|
| 416 | 416 | } |
|
| 417 | + | ||
| 418 | + | /// Where one image is: the hub, a runner holding it, or a runner fetching |
|
| 419 | + | /// it from the swarm now. |
|
| 420 | + | pub struct Place { |
|
| 421 | + | pub name: String, |
|
| 422 | + | /// `hub`, `holds` or `fetching`. |
|
| 423 | + | pub kind: &'static str, |
|
| 424 | + | pub title: String, |
|
| 425 | + | } |
|
| 426 | + | ||
| 427 | + | pub struct ImageRow { |
|
| 428 | + | pub hash: String, |
|
| 429 | + | pub arch: String, |
|
| 430 | + | /// Its size, when the hub keeps it (runners do not say). |
|
| 431 | + | pub size: Option<i64>, |
|
| 432 | + | pub places: Vec<Place>, |
|
| 433 | + | /// Who sent it to the hub, and when. |
|
| 434 | + | pub sent: Option<(String, i64)>, |
|
| 435 | + | /// Repositories whose jobs ran on it in the last 90 days. |
|
| 436 | + | pub uses: Vec<(String, i64, i64)>, |
|
| 437 | + | pub last_used: i64, |
|
| 438 | + | } |
|
| 439 | + | ||
| 440 | + | impl ImageRow { |
|
| 441 | + | /// Nowhere a job could get it: not on the hub, on no runner. |
|
| 442 | + | pub fn stranded(&self) -> bool { |
|
| 443 | + | self.places.is_empty() |
|
| 444 | + | } |
|
| 445 | + | } |
|
| 446 | + | ||
| 447 | + | #[derive(Template)] |
|
| 448 | + | #[template(path = "admin_images.html")] |
|
| 449 | + | pub struct ImagesPage { |
|
| 450 | + | shell: Shell, |
|
| 451 | + | images: Vec<ImageRow>, |
|
| 452 | + | on_hub: usize, |
|
| 453 | + | /// Runners in the image swarm, as "name (site)". |
|
| 454 | + | swarm: Vec<String>, |
|
| 455 | + | } |
|
| 456 | + | ||
| 457 | + | /// `GET /admin/runners/images` (C0, docs/cylinder.md): every runner image, |
|
| 458 | + | /// where it is and what uses it. |
|
| 459 | + | pub async fn images(State(app): State<Shared>, viewer: Viewer) -> Result<Page<ImagesPage>, Error> { |
|
| 460 | + | let shell = Shell::new(&app, "/admin/runners/images", &viewer); |
|
| 461 | + | require_admin(&viewer, &shell)?; |
|
| 462 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 463 | + | let kept = crate::images::kept(&app.db).await.map_err(e)?; |
|
| 464 | + | let uses = crate::images::uses(&app.db, db::now_ms() - 90 * 24 * 3600 * 1000).await.map_err(e)?; |
|
| 465 | + | let archs = crate::images::reported_archs(&app.db).await.map_err(e)?; |
|
| 466 | + | let list = runners::list(&app.db).await.map_err(e)?; |
|
| 467 | + | let fetching = app.swarm.fetching_now(); |
|
| 468 | + | let mut rows: HashMap<String, ImageRow> = HashMap::new(); |
|
| 469 | + | let row = |rows: &mut HashMap<String, ImageRow>, hash: &str| -> () { |
|
| 470 | + | rows.entry(hash.to_ascii_lowercase()).or_insert_with(|| ImageRow { |
|
| 471 | + | hash: hash.to_ascii_lowercase(), |
|
| 472 | + | arch: archs.get(&hash.to_ascii_lowercase()).cloned().unwrap_or_default(), |
|
| 473 | + | size: None, |
|
| 474 | + | places: Vec::new(), |
|
| 475 | + | sent: None, |
|
| 476 | + | uses: Vec::new(), |
|
| 477 | + | last_used: 0, |
|
| 478 | + | }); |
|
| 479 | + | }; |
|
| 480 | + | for k in &kept { |
|
| 481 | + | row(&mut rows, &k.hash); |
|
| 482 | + | let r = rows.get_mut(&k.hash.to_ascii_lowercase()).expect("added"); |
|
| 483 | + | r.size = Some(k.size); |
|
| 484 | + | if r.arch.is_empty() { |
|
| 485 | + | r.arch = k.arch.clone(); |
|
| 486 | + | } |
|
| 487 | + | r.sent = Some((k.uploader.clone(), k.created_ms)); |
|
| 488 | + | r.places.push(Place { name: "hub".into(), kind: "hub", title: format!("{} chunks kept by the hub", k.chunks) }); |
|
| 489 | + | } |
|
| 490 | + | let mut swarm = Vec::new(); |
|
| 491 | + | for runner in &list { |
|
| 492 | + | if let Some(a) = app.swarm.addr(runner.id) { |
|
| 493 | + | swarm.push(if a.site.is_empty() { runner.name.clone() } else { format!("{} ({})", runner.name, a.site) }); |
|
| 494 | + | } |
|
| 495 | + | for h in runner.image_list().unwrap_or_default() { |
|
| 496 | + | row(&mut rows, &h); |
|
| 497 | + | rows.get_mut(&h.to_ascii_lowercase()).expect("added").places.push(Place { |
|
| 498 | + | name: runner.name.clone(), |
|
| 499 | + | kind: "holds", |
|
| 500 | + | title: format!("{} holds it ({})", runner.name, runner.status()), |
|
| 501 | + | }); |
|
| 502 | + | } |
|
| 503 | + | } |
|
| 504 | + | for (image, who) in &fetching { |
|
| 505 | + | row(&mut rows, image); |
|
| 506 | + | for id in who { |
|
| 507 | + | if let Some(runner) = list.iter().find(|r| r.id == *id) { |
|
| 508 | + | rows.get_mut(image).expect("added").places.push(Place { |
|
| 509 | + | name: runner.name.clone(), |
|
| 510 | + | kind: "fetching", |
|
| 511 | + | title: format!("{} is fetching it from the swarm now", runner.name), |
|
| 512 | + | }); |
|
| 513 | + | } |
|
| 514 | + | } |
|
| 515 | + | } |
|
| 516 | + | for u in uses { |
|
| 517 | + | if !jet::images::is_hash(&u.image) { |
|
| 518 | + | continue; |
|
| 519 | + | } |
|
| 520 | + | row(&mut rows, &u.image); |
|
| 521 | + | let r = rows.get_mut(&u.image).expect("added"); |
|
| 522 | + | r.last_used = r.last_used.max(u.last_ms); |
|
| 523 | + | r.uses.push((format!("{}/{}", u.owner, u.name), u.jobs, u.last_ms)); |
|
| 524 | + | } |
|
| 525 | + | let mut images: Vec<ImageRow> = rows.into_values().collect(); |
|
| 526 | + | images.sort_by(|a, b| b.last_used.cmp(&a.last_used).then_with(|| a.hash.cmp(&b.hash))); |
|
| 527 | + | let on_hub = kept.len(); |
|
| 528 | + | Ok(Page(ImagesPage { shell, images, on_hub, swarm })) |
|
| 529 | + | } |
modified crates/hub/tests/flow.rs +57 −0
| … | 3049 unchanged lines |
||
| 3050 | 3050 | .await; |
|
| 3051 | 3051 | assert_eq!(st, StatusCode::UNAUTHORIZED); |
|
| 3052 | 3052 | } |
|
| 3053 | + | ||
| 3054 | + | /// Admin › Runners › Images (C0): each image, where it is and what uses it. |
|
| 3055 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 3056 | + | async fn the_images_page_says_where_each_image_is_and_what_uses_it() { |
|
| 3057 | + | let h = hub().await; |
|
| 3058 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 3059 | + | create(&h.app, "andy", "demo").await; |
|
| 3060 | + | let work = tempfile::tempdir().unwrap(); |
|
| 3061 | + | let c = client(&format!("{}/andy/demo", h.base), work.path()); |
|
| 3062 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 3063 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 3064 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 3065 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3066 | + | // A runner holds another image and shares it; the hub keeps a third. |
|
| 3067 | + | let held = "cd".repeat(32); |
|
| 3068 | + | let kept = "ef".repeat(32); |
|
| 3069 | + | let body = serde_json::json!({ "runner": "earth-1", "runtimes": ["wasm"], "images": [held], "swarm": { "addr": "10.0.0.1:7401", "site": "home", "cert": "11".repeat(32) } }); |
|
| 3070 | + | let (st, out) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(body.to_string())).await; |
|
| 3071 | + | assert!(st == StatusCode::NO_CONTENT || st == StatusCode::OK, "{st} {out}"); |
|
| 3072 | + | sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, 1048576, 'x86_64', 9, 'andy', 1)") |
|
| 3073 | + | .bind(&kept) |
|
| 3074 | + | .execute(&h.app.db) |
|
| 3075 | + | .await |
|
| 3076 | + | .unwrap(); |
|
| 3077 | + | ||
| 3078 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 3079 | + | .oneshot( |
|
| 3080 | + | Request::post("/register") |
|
| 3081 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 3082 | + | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery")) |
|
| 3083 | + | .unwrap(), |
|
| 3084 | + | ) |
|
| 3085 | + | .await |
|
| 3086 | + | .unwrap(); |
|
| 3087 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 3088 | + | let carol = db::accounts::account_by_name(&h.app.db, "carol").await.unwrap().unwrap(); |
|
| 3089 | + | db::accounts::set_admin(&h.app.db, carol.id, true).await.unwrap(); |
|
| 3090 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 3091 | + | .oneshot(Request::get("/admin/runners/images").header(header::COOKIE, cookie).body(Body::empty()).unwrap()) |
|
| 3092 | + | .await |
|
| 3093 | + | .unwrap(); |
|
| 3094 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 3095 | + | let page = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 3096 | + | let row = |hash: &str| { |
|
| 3097 | + | let at = page.find(&format!("title=\"{hash}\"")).unwrap_or_else(|| panic!("{hash} is not on the page")); |
|
| 3098 | + | let end = page[at..].find("</tr>").unwrap(); |
|
| 3099 | + | page[at..at + end].to_string() |
|
| 3100 | + | }; |
|
| 3101 | + | let pinned = row(ROOTFS); |
|
| 3102 | + | assert!(pinned.contains("nowhere") && pinned.contains("andy/demo") && pinned.contains("3 jobs"), "{pinned}"); |
|
| 3103 | + | let on_runner = row(&held); |
|
| 3104 | + | assert!(on_runner.contains("img-holds") && on_runner.contains("earth-1") && !on_runner.contains("nowhere"), "{on_runner}"); |
|
| 3105 | + | let on_hub = row(&kept); |
|
| 3106 | + | assert!(on_hub.contains("img-hub") && on_hub.contains("1.0 MiB") && on_hub.contains("x86_64"), "{on_hub}"); |
|
| 3107 | + | assert!(page.contains("earth-1 (home)"), "the swarm is named"); |
|
| 3108 | + | assert_eq!(call(&h.app, "GET", "/admin/runners/images", None, None).await.0, StatusCode::NOT_FOUND, "administrators only"); |
|
| 3109 | + | } |
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 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). **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409). |
|
| 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). **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409). |
|
| 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 |
modified templates/admin.html +1 −0
| … | 16 unchanged lines |
||
| 17 | 17 | <a href="#assistants">AI assistants</a> |
|
| 18 | 18 | <a href="#audit">Audit log</a> |
|
| 19 | 19 | <a href="/admin/runners">Runners ↗</a> |
|
| 20 | + | <a href="/admin/runners/images">Images ↗</a> |
|
| 20 | 21 | <a href="/admin/storage">Storage and maintenance ↗</a> |
|
| 21 | 22 | </nav> |
|
| 22 | 23 | <div class="sections"> |
|
| … | 74 unchanged lines |
modified templates/admin_runner_settings.html +1 −1
| … | 1 unchanged lines |
||
| 2 | 2 | {% block title %}Runner settings · Administration{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | 4 | <div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div> |
|
| 5 | - | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/settings" class="on">settings</a></span></div> |
|
| 5 | + | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings" class="on">settings</a></span></div> |
|
| 6 | 6 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 7 | 7 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 8 | 8 | <form class="card wide" method="post" action="/admin/runners/settings"> |
|
| … | 19 unchanged lines |
modified templates/admin_runners.html +1 −1
| … | 2 unchanged lines |
||
| 3 | 3 | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | 4 | {% block content %} |
|
| 5 | 5 | <div class="head"><h1>Runners <small>{{ runners.len()|plural("runner", "runners") }} · <a href="/admin">Administration</a></small></h1></div> |
|
| 6 | - | <div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 6 | + | <div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 7 | 7 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | 8 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | 9 | {% if let Some(t) = new_token %} |
|
| … | 53 unchanged lines |
modified templates/style.css +7 −0
| … | 986 unchanged lines |
||
| 987 | 987 | @media (prefers-reduced-motion: reduce) { |
|
| 988 | 988 | .oxy-wait header .brand svg circle[r="10"], .oxy-wait-mark svg circle[r="10"] { animation: none; stroke-dasharray: none; } |
|
| 989 | 989 | } |
|
| 990 | + | ||
| 991 | + | /* Admin › Runners › Images (C0, docs/cylinder.md): where each image is. */ |
|
| 992 | + | table.images td { vertical-align: top; } |
|
| 993 | + | table.images tr.stranded td:first-child { box-shadow: inset 3px 0 0 var(--bad); } |
|
| 994 | + | .lbl.img-hub { color: var(--acc); border-color: var(--acc); } |
|
| 995 | + | .lbl.img-holds { color: var(--ok); border-color: var(--ok); } |
|
| 996 | + | .lbl.img-fetching { color: var(--warn); border-color: var(--warn); } |