OXYGENHUB

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.