Runners are offered only jobs whose image they hold (closes #73) b0612e7dce30
A claim may list the rootfs images the runner holds (OxygenFlow sends them); Room carries the list and fits() requires the job sandbox's rootfs to be among them. A runner that does not say may still take any job. Pins oxygenflow with the claim field. A test shows a runner without the image gets nothing and one with it gets the job.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 00:45 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b0612e7dce309a59065e0cd0106ec17ebf559e7ac60b39fa96d14ae4768e2aaa- Depends on
- 070a7b64b368 380996db84e5 42fa95540468 749d9a5fcd9f 76bc5a058351 b5668108487f c4c87f481d05 d305b3b0afdc fd3db644df22
- Depended on by
- 951aed50c2f5 93501da2a162 4fa0da126b70 a46d15e72101 44a3d2ebaddd 2fb70d019851 92a797b20912 4d2e44d79b03 efd29a0033e8 7f5f6c056d8f eef677c48cd1 ced6d3afc5cf
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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
- 10 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Runners get only jobs they can run (#73).** A runner that lists the rootfs images it holds (OxygenFlow does, at every claim) is offered only jobs whose image is among them, rather than jobs it would fail for want of one. |
|
| 8 | + | ||
| 7 | 9 | - **Lost jobs end by themselves (#71).** A job still running five minutes past the job time limit is one its runner no longer reports (it restarted, or lost the hub as the job ended). The hub now looks for those once a minute and ends them as errors with the reason, skipping the jobs that depended on them and settling the run, so a runner is not shown busy with jobs it is not running. Earth's reset stranded five such jobs, closed by hand. |
|
| 8 | 10 | ||
| 9 | 11 | - **A GitHub-like look, now the default (#74).** Primer colours in light and dark, system fonts, rounded boxes, underline navigation, grey and green buttons, sentence case and pill labels, for readability and familiarity, light by default (the theme toggle and Settings → Appearance still offer dark and matching the system). The RetroPlay look stays available: `?look=classic` on any page keeps it for that browser, and `?look=github` returns to the default. |
|
| … | 19 unchanged lines |
modified crates/hub/src/db/flow.rs +10 −4
| … | 271 unchanged lines |
||
| 272 | 272 | } |
|
| 273 | 273 | } |
|
| 274 | 274 | ||
| 275 | - | /// What a runner can take on: free memory (MiB) and vCPUs, `None` for no limit. |
|
| 276 | - | #[derive(Clone, Copy, Debug, Default)] |
|
| 275 | + | /// What a runner can take on: free memory (MiB) and vCPUs, `None` for no |
|
| 276 | + | /// limit, and the rootfs images it holds, `None` when it does not say. |
|
| 277 | + | #[derive(Clone, Debug, Default)] |
|
| 277 | 278 | pub struct Room { |
|
| 278 | 279 | pub memory_mib: Option<u32>, |
|
| 279 | 280 | pub cpus: Option<u32>, |
|
| 281 | + | pub images: Option<std::collections::HashSet<String>>, |
|
| 280 | 282 | } |
|
| 281 | 283 | ||
| 282 | 284 | impl Room { |
|
| 283 | - | /// Whether a job's sandbox (its stored JSON) fits. |
|
| 285 | + | /// Whether a job's sandbox (its stored JSON) fits: its memory and vCPUs, |
|
| 286 | + | /// and its rootfs image among the runner's (oxygenhub#73). |
|
| 284 | 287 | pub fn fits(&self, sandbox_json: &str) -> bool { |
|
| 285 | 288 | let v: serde_json::Value = serde_json::from_str(sandbox_json).unwrap_or_default(); |
|
| 286 | 289 | let need = |k: &str| v[k].as_u64().unwrap_or(0); |
|
| 287 | - | self.memory_mib.is_none_or(|m| need("memory_mib") <= m as u64) && self.cpus.is_none_or(|c| need("cpus") <= c as u64) |
|
| 290 | + | let rootfs = v["rootfs"].as_str().unwrap_or(""); |
|
| 291 | + | self.memory_mib.is_none_or(|m| need("memory_mib") <= m as u64) |
|
| 292 | + | && self.cpus.is_none_or(|c| need("cpus") <= c as u64) |
|
| 293 | + | && (rootfs.is_empty() || self.images.as_ref().is_none_or(|have| have.contains(rootfs))) |
|
| 288 | 294 | } |
|
| 289 | 295 | } |
|
| 290 | 296 | ||
| … | 269 unchanged lines |
modified crates/hub/src/flow.rs +5 −1
| … | 255 unchanged lines |
||
| 256 | 256 | } |
|
| 257 | 257 | let runner = if me.source == "registered" { me.name.clone() } else { claimed }; |
|
| 258 | 258 | let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
|
| 259 | - | let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus }; |
|
| 259 | + | let room = fdb::Room { |
|
| 260 | + | memory_mib: req.free_memory_mib, |
|
| 261 | + | cpus: req.free_cpus, |
|
| 262 | + | images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()), |
|
| 263 | + | }; |
|
| 260 | 264 | // The labels it reports now, or those it registered with. |
|
| 261 | 265 | let labels: Vec<String> = match &req.info { |
|
| 262 | 266 | Some(i) => i.labels.clone(), |
|
| … | 468 unchanged lines |
modified crates/hub/tests/flow.rs +32 −0
| … | 1365 unchanged lines |
||
| 1366 | 1366 | assert_ne!(finish(&h.app, id, serde_json::json!({ "exit_code": 0 })).await, StatusCode::NO_CONTENT); |
|
| 1367 | 1367 | assert_eq!(db::flow::job(&h.app.db, id).await.unwrap().unwrap().status, "error"); |
|
| 1368 | 1368 | } |
|
| 1369 | + | ||
| 1370 | + | /// oxygenhub#73: a runner that lists the images it holds is offered only |
|
| 1371 | + | /// the jobs whose rootfs is among them; one that does not say gets any. |
|
| 1372 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1373 | + | async fn a_runner_is_offered_only_jobs_whose_image_it_holds() { |
|
| 1374 | + | let h = hub().await; |
|
| 1375 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 1376 | + | create(&h.app, "andy", "images").await; |
|
| 1377 | + | let url = format!("{}/andy/images", h.base); |
|
| 1378 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1379 | + | let c = client(&url, work.path()); |
|
| 1380 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 1381 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1382 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1383 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1384 | + | assert_eq!(runs_of(&h.app, "andy", "images").await.len(), 1); |
|
| 1385 | + | let claim_with = |images: Vec<String>| { |
|
| 1386 | + | let body = serde_json::json!({ "runner": "test-1", "runtimes": ["microvm"], "images": images }).to_string(); |
|
| 1387 | + | let app = h.app.clone(); |
|
| 1388 | + | async move { |
|
| 1389 | + | let (st, body) = call(&app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(body)).await; |
|
| 1390 | + | (st, body) |
|
| 1391 | + | } |
|
| 1392 | + | }; |
|
| 1393 | + | let (st, _) = claim_with(vec!["ab".repeat(32)]).await; |
|
| 1394 | + | assert_eq!(st, StatusCode::NO_CONTENT, "no job for a runner without the image"); |
|
| 1395 | + | let jobs = db::flow::jobs(&h.app.db, runs_of(&h.app, "andy", "images").await[0].id).await.unwrap(); |
|
| 1396 | + | let rootfs: serde_json::Value = serde_json::from_str(&jobs[0].sandbox).unwrap(); |
|
| 1397 | + | let rootfs = rootfs["rootfs"].as_str().unwrap().to_string(); |
|
| 1398 | + | let (st, body) = claim_with(vec!["ab".repeat(32), rootfs]).await; |
|
| 1399 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1400 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.