OXYGENHUB

A runner declines a job it cannot run b11e30f5031d

Before it fetches anything a runner checks the claim against itself:
the tier (/dev/kvm and [microvm], or the syscall tier's kernel features
and cgroup), the sandbox's architecture and the image's, that the image
is held and verifies, and that the sandbox's memory is free. A failed
check is sent to the hub as POST /flow/jobs/{id}/decline with the
reason (flow_core::api::Decline) instead of a failed job (spec §11.5).
refs #36
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 00:31 UTC
Signature
✔ signed by Andy McCall
Hash
b11e30f5031de89d52b3ce43304cc79231fb824b6c27217aefec049ebdf7b813
Depends on
3007d8f011fa bb643c252515 c3db750ed96e c5b702e59c1f e0918b887850 ea56c56eaf90 f53e6d47b56a f98a8f7881e0
Depended on by
77c8c24066ef 732b4de405e6 06a4c472b385 28cc603f737d 4be5e4cd0af6 a4b3f8cb9aae 89171fca079c b14aeaae683a 4cf0c99a1486
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
11 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A runner declines a job it cannot run (#36, spec §11.5).** Before fetching anything it checks the tier, the architecture, that the image is there and verifies, and that the sandbox's memory is free. If any check fails it hands the job back to the hub with the reason instead of running and failing it.
8 +
7 9
- **The syscall tier (#36, docs/m0.md §3).** `runtime "syscall"` jobs run without KVM, so boards such as the OrangePi RV2 can take them. The sandbox uses user, mount, PID, network, IPC and UTS namespaces on the image's unpacked tree, read-only. Landlock limits writes to `/src` and `/tmp`, and a seccomp filter refuses network sockets, tracing, mounts, BPF, keys, modules and io_uring. A delegated cgroup v2 group enforces the CPU and memory limits, and a job killed for memory is told so. A runner offers the tier only where all of this works. `flow tiers` shows which tiers a machine can run and why the others cannot, and `flow run` runs syscall jobs too.
8 10
9 11
- **A runner offers only the tiers it can run (#36).** It checks that `/dev/kvm` opens before offering the MicroVM tier, says which tiers it serves when it starts, and refuses to start with none, naming why, rather than claiming jobs it would fail.
…
22 unchanged lines
modified crates/flow-core/src/api.rs +8 −0
…
200 unchanged lines
201 201
    pub text: String,
202 202
}
203 203
204 +
/// `POST /api/v1/flow/jobs/{id}/decline` (spec §11.5): the runner will
205 +
/// not run a job it was handed, and says why. The hub queues the job
206 +
/// again and offers it to this runner no more until its profile changes.
207 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
208 +
pub struct Decline {
209 +
    pub reason: String,
210 +
}
211 +
204 212
/// `POST /api/v1/flow/jobs/{id}/log`.
205 213
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
206 214
pub struct LogBatch {
…
22 unchanged lines
modified crates/flow/src/runner.rs +100 −1
…
5 unchanged lines
6 6
use crate::home::{Home, RunnerConfig, RunnerKey};
7 7
use crate::{microvm, workspace};
8 8
use anyhow::{bail, Context, Result};
9 -
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT};
9 +
use flow_core::api::{Claim, ClaimRequest, Decline, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT};
10 10
use flow_core::{scope_tree_hash, Receipt};
11 11
use oxy_core::hash::ChangeHash;
12 12
use oxy_core::repo::Repo;
…
76 unchanged lines
89 89
        }
90 90
    }
91 91
92 +
    /// Hand job `job` back, saying why this runner will not run it.
93 +
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
94 +
        self.post(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
95 +
        Ok(())
96 +
    }
97 +
92 98
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
93 99
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
94 100
        Ok(())
…
209 unchanged lines
304 310
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
305 311
    }
306 312
    Ok((have, missing))
313 +
}
314 +
315 +
/// Why this runner cannot run `claim` now (spec §11.5), checked before
316 +
/// anything is fetched: the tier, the architecture, the image and the
317 +
/// memory. The hub chose it from what the runner last said, which may be
318 +
/// out of date.
319 +
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
320 +
    let sb = &claim.sandbox;
321 +
    match sb.runtime {
322 +
        flow_core::Runtime::Microvm => {
323 +
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
324 +
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
325 +
            }
326 +
            if home.config.microvm.is_none() {
327 +
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
328 +
            }
329 +
        }
330 +
        flow_core::Runtime::Syscall => {
331 +
            if let Err(e) = crate::syscall::available() {
332 +
                return Some(format!("the syscall tier cannot run here: {e}"));
333 +
            }
334 +
        }
335 +
        flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()),
336 +
    }
337 +
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
338 +
}
339 +
340 +
/// What stops `sb` running on this home's images, on a machine of
341 +
/// architecture `here` with `free_mib` of memory free.
342 +
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
343 +
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
344 +
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
345 +
    }
346 +
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
347 +
    let image = home.rootfs(&sb.rootfs);
348 +
    if !image.is_file() {
349 +
        return Some(format!("rootfs {short} is not on this runner"));
350 +
    }
351 +
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
352 +
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
353 +
    }
354 +
    if let Err(e) = home.verified(&image, &sb.rootfs) {
355 +
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
356 +
    }
357 +
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
358 +
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
359 +
    }
360 +
    None
361 +
}
362 +
363 +
/// `MemAvailable` from /proc/meminfo, in MiB.
364 +
fn available_memory_mib() -> Option<u64> {
365 +
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
366 +
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
367 +
    Some(kib / 1024)
307 368
}
308 369
309 370
pub fn serve(home: &Home) -> Result<()> {
…
66 unchanged lines
376 437
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
377 438
                    scope.spawn(move || {
378 439
                        let id = claim.job_id;
440 +
                        if let Some(why) = preflight(home, &claim) {
441 +
                            eprintln!("job {id}: declined: {why}");
442 +
                            if let Err(e) = hub.decline(id, &why) {
443 +
                                eprintln!("job {id}: decline: {e:#}");
444 +
                            }
445 +
                            held.lock().unwrap().remove(&id);
446 +
                            let mut u = used.lock().unwrap();
447 +
                            u.jobs -= 1;
448 +
                            u.memory_mib -= mem;
449 +
                            u.cpus -= cpus;
450 +
                            return;
451 +
                        }
379 452
                        eprintln!(
380 453
                            "job {id}: {} {} on {}",
381 454
                            claim.repo,
…
355 unchanged lines
737 810
#[cfg(test)]
738 811
mod tests {
739 812
    use super::*;
813 +
814 +
    #[test]
815 +
    fn a_runner_names_what_stops_a_sandbox() {
816 +
        let dir = tempfile::tempdir().unwrap();
817 +
        let home = Home::locate(Some(dir.path())).unwrap();
818 +
        let bytes = b"not really ext4";
819 +
        let hash = blake3::hash(bytes).to_hex().to_string();
820 +
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
821 +
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
822 +
        }))
823 +
        .unwrap();
824 +
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
825 +
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
826 +
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
827 +
        sb.rootfs = hash.clone();
828 +
        assert_eq!(problem(&sb, Some(4096)), None);
829 +
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
830 +
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
831 +
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
832 +
        sb.arch = Some("aarch64".into());
833 +
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
834 +
        sb.arch = None;
835 +
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
836 +
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
837 +
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
838 +
    }
740 839
741 840
    #[test]
742 841
    fn transient_network_errors_are_tried_again_and_others_are_not() {
…
187 unchanged lines
modified crates/flow/tests/syscall.rs +4 −2
…
1 unchanged lines
2 2
//! the syscall sandbox. Runs where `flow tiers` offers the syscall tier
3 3
//! (user namespaces, Landlock, seccomp and a delegated cgroup: each `flow`
4 4
//! runs in a delegated systemd user scope when one can be made) and a static
5 -
//! busybox is at /usr/bin/busybox; skipped otherwise.
5 +
//! busybox is at /usr/bin/busybox (or `FLOW_TEST_BUSYBOX`); skipped
6 +
//! otherwise.
6 7
7 8
use std::path::Path;
8 9
use std::process::Command;
…
22 unchanged lines
31 32
32 33
#[test]
33 34
fn a_pipeline_runs_confined_in_the_syscall_sandbox() {
34 -
    let busybox = Path::new("/usr/bin/busybox");
35 +
    let busybox = std::env::var_os("FLOW_TEST_BUSYBOX").map(std::path::PathBuf::from).unwrap_or_else(|| "/usr/bin/busybox".into());
36 +
    let busybox = busybox.as_path();
35 37
    let dir = tempfile::tempdir().unwrap();
36 38
    let home = dir.path().join("home");
37 39
    let tiers = flow(&home).arg("tiers").output().unwrap();
…
88 unchanged lines