OXYGENHUB
# da5118d25db2 oxygenflow / crates / flow / src / runner.rs
66.0 KiB · Rust · last changed Build caches for the syscall tier 17 days ago source blame history raw
1
//! `flow runner` (docs/m0.md §5): claim a job from OxygenHub, fetch the
2
//! repository into a cache with the job's token, write the state into a
3
//! workspace, run it in its tier, stream the log, report the receipt.
4
//! One job at a time.
5
6
use crate::home::{Home, RunnerConfig, RunnerKey};
7
use crate::{microvm, workspace};
8
use anyhow::{bail, Context, Result};
9
use flow_core::api::{Claim, ClaimRequest, Decline, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT};
10
use flow_core::{scope_tree_hash, Receipt};
11
use oxy_core::hash::ChangeHash;
12
use oxy_core::repo::Repo;
13
use std::path::PathBuf;
14
use std::time::{Duration, Instant};
15
16
/// A request the hub refused (4xx). On a job's endpoints, 409 means the
17
/// job no longer runs: it was cancelled, or given up.
18
#[derive(Debug)]
19
pub struct Refused {
20
    pub code: u16,
21
    pub text: String,
22
}
23
24
impl std::fmt::Display for Refused {
25
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26
        write!(f, "HTTP {}: {}", self.code, self.text)
27
    }
28
}
29
30
impl std::error::Error for Refused {}
31
32
/// Whether `e` is the hub saying the job no longer runs.
33
fn job_gone(e: &anyhow::Error) -> bool {
34
    e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409)
35
}
36
37
/// What a job ends with when its run was cancelled: nothing is reported,
38
/// as the hub has ended it already.
39
const CANCELLED: &str = "the run was cancelled";
40
41
fn cancelled() -> Finish {
42
    Finish {
43
        exit_code: None,
44
        duration_ms: 0,
45
        receipt: None,
46
        signed_receipt: None,
47
        error: Some(CANCELLED.into()),
48
        peak_disk_mib: None,
49
        peak_memory_mib: None,
50
    }
51
}
52
53
pub struct Hub {
54
    base: String,
55
    token: String,
56
    agent: ureq::Agent,
57
}
58
59
impl Hub {
60
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
61
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
62
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
63
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
64
    }
65
66
    /// POST to the hub. A server error (5xx) means nothing was committed,
67
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
68
    /// busy hub costs a job a few seconds, not its log lines or its claim.
69
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
70
        self.post_with(path, body, &RETRY_DELAYS)
71
    }
72
73
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
74
        let url = format!("{}/api/v1{path}", self.base);
75
        let r = retrying(&url, delays, false, || {
76
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
77
        })?;
78
        Ok(if r.status() == 204 { None } else { Some(r) })
79
    }
80
81
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
82
    /// away for a few minutes, as while a deploy restarts it, so a job's
83
    /// declines and uploads outlast it.
84
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
85
        let url = format!("{}/api/v1{path}", self.base);
86
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
87
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
88
        })?;
89
        Ok(if r.status() == 204 { None } else { Some(r) })
90
    }
91
92
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
93
        match self.post("/flow/claim", req)? {
94
            Some(r) => Ok(Some(r.into_json()?)),
95
            None => Ok(None),
96
        }
97
    }
98
99
    /// Hand job `job` back, saying why this runner will not run it.
100
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
101
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
102
        Ok(())
103
    }
104
105
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
106
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
107
        Ok(())
108
    }
109
110
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
111
    /// a blob of the job's repository and answers with its ObjectRef.
112
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
113
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
114
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
115
            self.agent
116
                .post(&url)
117
                .timeout(Duration::from_secs(600))
118
                .set("Authorization", &format!("Bearer {}", self.token))
119
                .set("Content-Type", "application/octet-stream")
120
                .send_bytes(bytes)
121
                .map_err(Box::new)
122
        })?;
123
        let v: serde_json::Value = r.into_json()?;
124
        v["blob"].as_str().map(String::from).context("the hub named no blob")
125
    }
126
127
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
128
    /// the hub keeps it as a blob and answers with its ObjectRef. The
129
    /// report's manifest follows through [`Hub::artifact`].
130
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
131
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
132
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
133
            self.agent
134
                .post(&url)
135
                .timeout(Duration::from_secs(600))
136
                .set("Authorization", &format!("Bearer {}", self.token))
137
                .set("Content-Type", "application/octet-stream")
138
                .set("X-Flow-Report-Path", path)
139
                .send_bytes(bytes)
140
                .map_err(Box::new)
141
        })?;
142
        let v: serde_json::Value = r.into_json()?;
143
        v["blob"].as_str().map(String::from).context("the hub named no blob")
144
    }
145
146
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
147
    /// cannot be reached or fails (5xx), try again after `first_wait`,
148
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
149
    /// already, say) is final. A result lost here leaves the job running
150
    /// on the hub until its time limit.
151
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
152
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
153
        let deadline = std::time::Instant::now() + patience;
154
        let mut wait = first_wait;
155
        loop {
156
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
157
                Ok(_) => return Ok(()),
158
                Err(ureq::Error::Status(code, r)) if code < 500 => {
159
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
160
                }
161
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
162
                Err(e) => e.to_string(),
163
            };
164
            if std::time::Instant::now() + wait > deadline {
165
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
166
            }
167
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
168
            std::thread::sleep(wait);
169
            wait = (wait * 2).min(Duration::from_secs(60));
170
        }
171
    }
172
}
173
174
/// How long a runner keeps trying to report a job's end.
175
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
176
177
/// Where a job's result waits until the hub has it, so a runner that
178
/// restarts still reports it.
179
fn pending_path(home: &Home, job: i64) -> PathBuf {
180
    home.root.join("pending").join(format!("{job}.json"))
181
}
182
183
/// Report `f`, keeping it on disk until the hub has it.
184
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
185
    let path = pending_path(home, job);
186
    let kept = std::fs::create_dir_all(path.parent().unwrap())
187
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
188
        .is_ok();
189
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
190
        Ok(()) => {}
191
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
192
    }
193
    if kept {
194
        let _ = std::fs::remove_file(&path);
195
    }
196
}
197
198
/// Results a previous run of the runner could not report, to send again.
199
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
200
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
201
        return Vec::new();
202
    };
203
    dir.filter_map(|e| {
204
        let p = e.ok()?.path();
205
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
206
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
207
        Some((job, f))
208
    })
209
    .collect()
210
}
211
212
/// `flow runner register`: exchange a one-time registration token from
213
/// the hub for this runner's credential, binding the runner's signing key,
214
/// and keep hub, credential and name in runner.toml.
215
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
216
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
217
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
218
    }
219
    let key = home.runner_key(name)?;
220
    let hub = hub.trim_end_matches('/').to_string();
221
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
222
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
223
    let req = Register {
224
        token: token.trim().to_string(),
225
        name: name.to_string(),
226
        identity: hex::encode(key.identity.encode()),
227
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
228
    };
229
    let url = format!("{hub}/api/v1/flow/runners/register");
230
    let got: Registered = match agent.post(&url).send_json(&req) {
231
        Ok(r) => r.into_json()?,
232
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
233
        Err(e) => bail!("{url}: {e}"),
234
    };
235
    let mut text = format!(
236
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
237
        got.credential, got.name
238
    );
239
    if let Some(ca) = &ca {
240
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
241
    }
242
    if !labels.is_empty() {
243
        text.push_str(&format!("labels = {labels:?}\n"));
244
    }
245
    let path = home.root.join(crate::home::RUNNER_TOML);
246
    {
247
        use std::io::Write;
248
        use std::os::unix::fs::OpenOptionsExt;
249
        let tmp = path.with_extension("toml.tmp");
250
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
251
        f.write_all(text.as_bytes())?;
252
        f.sync_all()?;
253
        std::fs::rename(&tmp, &path)?;
254
    }
255
    Ok(got)
256
}
257
258
/// Waits before trying a hub request again after a server error.
259
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
260
261
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
262
/// all, longer than a hub takes to restart.
263
const PATIENT_DELAYS: [Duration; 15] = {
264
    let mut d = [Duration::from_secs(15); 15];
265
    d[0] = Duration::from_secs(1);
266
    d[1] = Duration::from_secs(2);
267
    d[2] = Duration::from_secs(4);
268
    d[3] = Duration::from_secs(8);
269
    d
270
};
271
272
/// Send a request until the hub answers, waiting `delays` in turn after a
273
/// server error (5xx: nothing was committed) and, when `connection`, after
274
/// the hub could not be reached at all. A refusal (4xx) is final, as a
275
/// [`Refused`] the caller can look at.
276
fn retrying(
277
    url: &str,
278
    delays: &[Duration],
279
    connection: bool,
280
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
281
) -> Result<ureq::Response> {
282
    let mut delays = delays.iter();
283
    loop {
284
        let why = match send().map_err(|e| *e) {
285
            Ok(r) => return Ok(r),
286
            Err(ureq::Error::Status(code, r)) if code < 500 => {
287
                let text = r.into_string().unwrap_or_default();
288
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
289
            }
290
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
291
            Err(e) if connection => e.to_string(),
292
            Err(e) => bail!("{url}: {e}"),
293
        };
294
        match delays.next() {
295
            Some(d) => std::thread::sleep(*d),
296
            None => bail!("{url}: {why}"),
297
        }
298
    }
299
}
300
301
/// What the runner may run at once (flow.toml `[runner]`): at most
302
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
303
#[derive(Debug, Default, PartialEq, Eq)]
304
pub struct Budget {
305
    pub max_jobs: usize,
306
    pub memory_mib: Option<u32>,
307
    pub cpus: Option<u32>,
308
}
309
310
/// What is in use now.
311
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
312
pub struct Used {
313
    pub jobs: usize,
314
    pub memory_mib: u32,
315
    pub cpus: u32,
316
}
317
318
impl Budget {
319
    pub fn of(cfg: &RunnerConfig) -> Budget {
320
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
321
    }
322
323
    /// The room to ask the hub for, or `None` when no job more can start.
324
    /// A lone job may use the whole budget even if it asks for more, so a
325
    /// large sandbox is never starved.
326
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
327
        if used.jobs >= self.max_jobs {
328
            return None;
329
        }
330
        if used.jobs == 0 {
331
            return Some((None, None));
332
        }
333
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
334
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
335
        if mem == Some(0) || cpus == Some(0) {
336
            return None;
337
        }
338
        Some((mem, cpus))
339
    }
340
}
341
342
/// The isolation tiers this machine can honour now (spec §11.1): the
343
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
344
/// flow.toml pins Firecracker; the syscall tier when this binary may use
345
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
346
/// `Err` says why there are none; the reasons for missing tiers come back
347
/// with the ones there are.
348
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
349
    let mut have = Vec::new();
350
    let mut missing = Vec::new();
351
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
352
    match (kvm, home.config.microvm.is_some()) {
353
        (Ok(_), true) => have.push("microvm".to_string()),
354
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
355
        (Err(e), _) => missing.push(format!(
356
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
357
        )),
358
    }
359
    match crate::syscall::available() {
360
        Ok(()) => have.push("syscall".to_string()),
361
        Err(e) => missing.push(format!("syscall: {e}")),
362
    }
363
    if have.is_empty() {
364
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
365
    }
366
    Ok((have, missing))
367
}
368
369
/// Fetch `claim`'s image from the hub when this home lacks it
370
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
371
/// leaves it missing, and the preflight declines the job as before.
372
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) {
373
    let hash = &claim.sandbox.rootfs;
374
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
375
        return;
376
    }
377
    let note = |text: String| {
378
        eprintln!("job {}: {text}", claim.job_id);
379
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
380
    };
381
    // One fetch of an image at a time: two jobs wanting it wait for one.
382
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
383
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
384
    if home.rootfs(hash).is_file() {
385
        return;
386
    }
387
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
388
        Ok(c) => c,
389
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
390
    };
391
    let fetched = match swarm {
392
        Some(sw) => {
393
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
394
            sw.fetch(home, hash)
395
        }
396
        None => {
397
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
398
            client.fetch(home, hash)
399
        }
400
    };
401
    match fetched {
402
        Ok(Some(m)) => note(format!(
403
            "image {}: fetched {} chunks, {}{} in {:.1} s",
404
            &hash[..12],
405
            m.chunks,
406
            crate::images::human(m.bytes),
407
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
408
            m.elapsed.as_secs_f64()
409
        )),
410
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
411
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
412
    }
413
}
414
415
/// Why this runner cannot run `claim` now (spec §11.5), checked before
416
/// anything is fetched: the tier, the architecture, the image and the
417
/// memory. The hub chose it from what the runner last said, which may be
418
/// out of date.
419
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
420
    let sb = &claim.sandbox;
421
    match sb.runtime {
422
        flow_core::Runtime::Microvm => {
423
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
424
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
425
            }
426
            if home.config.microvm.is_none() {
427
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
428
            }
429
        }
430
        flow_core::Runtime::Syscall => {
431
            if let Err(e) = crate::syscall::available() {
432
                return Some(format!("the syscall tier cannot run here: {e}"));
433
            }
434
        }
435
        flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()),
436
    }
437
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
438
}
439
440
/// What stops `sb` running on this home's images, on a machine of
441
/// architecture `here` with `free_mib` of memory free.
442
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
443
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
444
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
445
    }
446
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
447
    let image = home.rootfs(&sb.rootfs);
448
    if !image.is_file() {
449
        return Some(format!("rootfs {short} is not on this runner"));
450
    }
451
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
452
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
453
    }
454
    if let Err(e) = home.verified(&image, &sb.rootfs) {
455
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
456
    }
457
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
458
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
459
    }
460
    None
461
}
462
463
/// `MemAvailable` from /proc/meminfo, in MiB.
464
fn available_memory_mib() -> Option<u64> {
465
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
466
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
467
    Some(kib / 1024)
468
}
469
470
pub fn serve(home: &Home) -> Result<()> {
471
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
472
    // A runner offers only what it can run: a machine without KVM claims
473
    // nothing rather than taking MicroVM jobs and failing them (#36).
474
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
475
    for why in &missing {
476
        eprintln!("flow runner: not offering {why}");
477
    }
478
    let hub = Hub::new(&cfg)?;
479
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
480
    let budget = Budget::of(&cfg);
481
    drain_on_stop();
482
    let key = home.runner_key(&name)?;
483
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
484
    // peers still runs jobs, fetching images from the hub alone.
485
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
486
        eprintln!("flow runner: not joining the swarm: {e:#}");
487
        None
488
    });
489
    eprintln!(
490
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
491
        cfg.hub,
492
        budget.max_jobs,
493
        runtimes.join(", "),
494
        key.identity.hash().to_hex()
495
    );
496
    let used = std::sync::Mutex::new(Used::default());
497
    let mut idle = Duration::from_secs(2);
498
    let pending = report_pending(home);
499
    // The jobs held now, told to the hub at each claim so it can end the
500
    // ones this runner lost: a job is held from its claim until its result
501
    // is reported.
502
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
503
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
504
    std::thread::scope(|scope| {
505
        for (job, f) in pending {
506
            let (hub, held) = (&hub, &held);
507
            scope.spawn(move || {
508
                eprintln!("job {job}: reporting the result kept from before the restart");
509
                report_finish(home, hub, job, &f);
510
                held.lock().unwrap().remove(&job);
511
            });
512
        }
513
        // The speed (spec §11.1): measured now, and again every 15 minutes
514
        // while no job runs, so a job never shares the CPU with it.
515
        let measure = || {
516
            let (speed, ms) = crate::facts::bench();
517
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
518
            speed
519
        };
520
        let mut speed = measure();
521
        let mut benched = Instant::now();
522
        let mut said_draining = false;
523
        loop {
524
            // Draining (oxygenflow#50): told to stop, claim nothing more and
525
            // stop once every job held is done and reported.
526
            if stopping() {
527
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
528
                if jobs == 0 && holding == 0 {
529
                    eprintln!("flow runner {name}: drained, stopping");
530
                    break;
531
                }
532
                if !said_draining {
533
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
534
                    said_draining = true;
535
                }
536
                std::thread::sleep(Duration::from_millis(500));
537
                continue;
538
            }
539
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
540
                speed = measure();
541
                benched = Instant::now();
542
            }
543
            let room = budget.room(*used.lock().unwrap());
544
            let Some((free_memory_mib, free_cpus)) = room else {
545
                std::thread::sleep(Duration::from_millis(500));
546
                continue;
547
            };
548
            let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times();
549
            let req = ClaimRequest {
550
                runner: name.clone(),
551
                runtimes: runtimes.clone(),
552
                free_memory_mib,
553
                free_cpus,
554
                info: Some(RunnerInfo {
555
                    version: env!("OXY_VERSION_LONG").into(),
556
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
557
                }),
558
                // Read each time: an image imported while the runner runs counts.
559
                images: Some(home.images()),
560
                image_archs: Some(home.image_archs()),
561
                holding: Some(held.lock().unwrap().iter().copied().collect()),
562
                fetches_images: true,
563
                facts: Some(crate::facts::now(home, Some(speed))),
564
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
565
                caches: Some(cache_times.keys().cloned().collect()),
566
                cache_times: Some(cache_times),
567
            };
568
            match hub.claim(&req) {
569
                Ok(Some(claim)) => {
570
                    idle = Duration::from_secs(2);
571
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
572
                    {
573
                        let mut u = used.lock().unwrap();
574
                        u.jobs += 1;
575
                        u.memory_mib += mem;
576
                        u.cpus += cpus;
577
                    }
578
                    held.lock().unwrap().insert(claim.job_id);
579
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
580
                    scope.spawn(move || {
581
                        let id = claim.job_id;
582
                        fetch_image(home, hub, cfg, swarm, &claim);
583
                        if let Some(why) = preflight(home, &claim) {
584
                            eprintln!("job {id}: declined: {why}");
585
                            if let Err(e) = hub.decline(id, &why) {
586
                                eprintln!("job {id}: decline: {e:#}");
587
                            }
588
                            held.lock().unwrap().remove(&id);
589
                            let mut u = used.lock().unwrap();
590
                            u.jobs -= 1;
591
                            u.memory_mib -= mem;
592
                            u.cpus -= cpus;
593
                            return;
594
                        }
595
                        eprintln!(
596
                            "job {id}: {} {} on {}",
597
                            claim.repo,
598
                            claim.job.name,
599
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
600
                        );
601
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
602
                            exit_code: None,
603
                            duration_ms: 0,
604
                            receipt: None,
605
                            signed_receipt: None,
606
                            error: Some(format!("{e:#}")),
607
                            peak_disk_mib: None,
608
                            peak_memory_mib: None,
609
                        });
610
                        eprintln!(
611
                            "job {id}: exit {:?}{}",
612
                            finish.exit_code,
613
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
614
                        );
615
                        if finish.error.as_deref() != Some(CANCELLED) {
616
                            report_finish(home, hub, id, &finish);
617
                        }
618
                        held.lock().unwrap().remove(&id);
619
                        let mut u = used.lock().unwrap();
620
                        u.jobs -= 1;
621
                        u.memory_mib -= mem;
622
                        u.cpus -= cpus;
623
                    });
624
                }
625
                Ok(None) => std::thread::sleep(idle),
626
                Err(e) => {
627
                    eprintln!("claim: {e:#}");
628
                    std::thread::sleep(idle);
629
                    idle = (idle * 2).min(Duration::from_secs(60));
630
                }
631
            }
632
        }
633
        Ok(())
634
    })
635
}
636
637
/// How many times the runner was told to stop (SIGTERM or SIGINT).
638
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
639
640
extern "C" fn on_stop(_: libc::c_int) {
641
    // A second stop means now: only async-signal-safe calls here.
642
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
643
        unsafe { libc::_exit(143) };
644
    }
645
}
646
647
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
648
/// so restarting a runner never kills the job it runs: the unit's
649
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
650
fn drain_on_stop() {
651
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
652
    unsafe {
653
        libc::signal(libc::SIGTERM, handler);
654
        libc::signal(libc::SIGINT, handler);
655
    }
656
}
657
658
fn stopping() -> bool {
659
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
660
}
661
662
/// Batches records to the hub every second (or every 200 lines). A quiet
663
/// job's sink still asks the hub every few seconds whether the job runs,
664
/// with an empty batch; once the hub answers 409 the job is not wanted.
665
struct LogSink<'a> {
666
    hub: &'a Hub,
667
    job: i64,
668
    buf: Vec<Record>,
669
    last: Instant,
670
    gone: bool,
671
}
672
673
/// The most log lines sent at once, as a busy job's sink flushes them.
674
const BATCH: usize = 200;
675
676
/// The most log lines kept while the hub cannot be reached.
677
const MAX_UNSENT: usize = 50_000;
678
679
/// How often a quiet job asks whether it was cancelled.
680
const HEARTBEAT: Duration = Duration::from_secs(5);
681
682
impl microvm::Sink for LogSink<'_> {
683
    fn record(&mut self, r: Record) {
684
        self.push(r)
685
    }
686
687
    fn cancelled(&mut self) -> bool {
688
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
689
            self.send();
690
        }
691
        self.gone
692
    }
693
}
694
695
impl LogSink<'_> {
696
    fn push(&mut self, mut r: Record) {
697
        if self.gone {
698
            return;
699
        }
700
        if r.text.len() > MAX_RECORD_TEXT {
701
            let mut cut = MAX_RECORD_TEXT;
702
            while !r.text.is_char_boundary(cut) {
703
                cut -= 1;
704
            }
705
            r.text.truncate(cut);
706
        }
707
        self.buf.push(r);
708
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
709
            self.flush();
710
        }
711
    }
712
713
    fn flush(&mut self) {
714
        if !self.buf.is_empty() {
715
            self.send();
716
        }
717
        self.last = Instant::now();
718
    }
719
720
    /// Send what is buffered, even nothing, in batches of at most
721
    /// `BATCH` lines.
722
    fn send(&mut self) {
723
        loop {
724
            if self.gone {
725
                return;
726
            }
727
            let n = self.buf.len().min(BATCH);
728
            let batch: Vec<Record> = self.buf.drain(..n).collect();
729
            match self.hub.log(self.job, batch.clone()) {
730
                Ok(()) => {}
731
                Err(e) if job_gone(&e) => self.gone = true,
732
                Err(e) => {
733
                    // The hub is away (a restart, say): keep the lines for
734
                    // the next batch, up to a bound, so the log loses none
735
                    // (oxygenflow#49).
736
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
737
                    let mut kept = batch;
738
                    kept.append(&mut self.buf);
739
                    let over = kept.len().saturating_sub(MAX_UNSENT);
740
                    if over > 0 {
741
                        kept.drain(..over);
742
                        kept.insert(
743
                            0,
744
                            Record {
745
                                t: 0,
746
                                stream: "flow".into(),
747
                                text: format!("({over} log lines lost while the hub could not be reached)"),
748
                            },
749
                        );
750
                    }
751
                    self.buf = kept;
752
                    break;
753
                }
754
            }
755
            if self.buf.is_empty() {
756
                break;
757
            }
758
        }
759
        self.last = Instant::now();
760
    }
761
}
762
763
/// Held while a job takes its cache disks and images its workspace (see
764
/// `run_claim`): one job at a time on the runner's disk and cache pool.
765
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
766
767
/// Held while the first clone of any repository passes its token through
768
/// the process environment.
769
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
770
771
/// One lock per cached repository: fetches into it, and exports from it,
772
/// never overlap, while jobs of other repositories go on. A slow repository
773
/// (one kept in a bucket, say) once held every job on the runner waiting
774
/// behind a single global lock.
775
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
776
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
777
        std::sync::OnceLock::new();
778
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
779
    locks.entry(name.to_string()).or_default().clone()
780
}
781
782
/// Seconds, to one decimal place, for the job's log.
783
fn secs(d: std::time::Duration) -> String {
784
    format!("{:.1} s", d.as_secs_f64())
785
}
786
787
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
788
    let started = Instant::now();
789
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
790
    let note =
791
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
792
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
793
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
794
    // Preparing a workspace: fetches into a cached repository, and exports
795
    // from it, are one job at a time per repository (`repo_lock`); taking
796
    // cache disks and imaging the workspace are one job at a time on the
797
    // runner (`PREPARE`); a first clone, whose token goes through the process
798
    // environment, one at a time (`CLONE`).
799
    let main_lock = repo_lock(&claim.repo);
800
    let asked = Instant::now();
801
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
802
    if asked.elapsed().as_secs() >= 2 {
803
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
804
    }
805
    let fetching = Instant::now();
806
    let repo = cached_repo(home, cfg, claim)?;
807
    let fetched = fetching.elapsed();
808
    let mut preparing = None;
809
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
810
    let _ = std::fs::remove_dir_all(&scratch);
811
    std::fs::create_dir_all(scratch.join("tree"))?;
812
    let result = (|| -> Result<Finish> {
813
        let exporting = Instant::now();
814
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
815
        main_guard.take();
816
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
817
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
818
        // workspace and of the scope hash.
819
        for sub in &claim.subrepos {
820
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
821
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
822
            }
823
            note(
824
                &mut sink,
825
                format!(
826
                    "fetching subrepo {} at {} for {}",
827
                    sub.repo,
828
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
829
                    sub.path
830
                ),
831
            );
832
            let sub_lock = repo_lock(&sub.repo);
833
            let asked = Instant::now();
834
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
835
            if asked.elapsed().as_secs() >= 2 {
836
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
837
            }
838
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
839
            let sub_heads: Vec<ChangeHash> =
840
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
841
            let dir = scratch.join("tree").join(&sub.path);
842
            std::fs::create_dir_all(&dir)?;
843
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
844
            drop(sub_guard);
845
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
846
        }
847
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
848
        // The syscall tier runs on the tree itself; a microVM gets an image.
849
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
850
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
851
        // Disk mounts that name a cache start from a kept disk (#28): a
852
        // microVM takes an image already in place as it is, the syscall tier
853
        // a directory (#70). Taken before the workspace is imaged: the
854
        // sources the disk was built from give unchanged files their old
855
        // times back (#52).
856
        let asked = Instant::now();
857
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
858
        if asked.elapsed().as_secs() >= 2 {
859
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
860
        }
861
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
862
        let form = if syscall_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
863
        let cache_ctx =
864
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
865
        let mut cached: Vec<(usize, String, String)> = Vec::new();
866
        let mut built_from: Option<crate::cache::Sources> = None;
867
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
868
            let Some(name) = &m.cache else { continue };
869
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
870
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
871
                Some(t) => {
872
                    if built_from.is_none() {
873
                        built_from = t.sources;
874
                    }
875
                    t.how
876
                }
877
                None => format!("cache {name}: none kept yet, an empty disk"),
878
            };
879
            note(&mut sink, format!("{} ({})", how, m.target));
880
            cached.push((i, key, how));
881
        }
882
        let sources = if cached.is_empty() {
883
            crate::cache::Sources::new()
884
        } else {
885
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
886
            if built_from.is_some() {
887
                note(&mut sink, format!("cache: {restored} of {} files unchanged since the cached build, their times kept", sources.len()));
888
            }
889
            sources
890
        };
891
        if !syscall_tier {
892
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
893
            std::fs::remove_dir_all(scratch.join("tree"))?;
894
        }
895
        preparing.take();
896
        // Cancelled while it was being prepared: no sandbox.
897
        if microvm::Sink::cancelled(&mut sink) {
898
            return Ok(cancelled());
899
        }
900
        note(
901
            &mut sink,
902
            format!(
903
                "{}: {} {}, {} MiB, rootfs {}",
904
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
905
                claim.sandbox.cpus,
906
                if syscall_tier { "CPU" } else { "vCPU" },
907
                claim.sandbox.memory_mib,
908
                &claim.sandbox.rootfs[..12]
909
            ),
910
        );
911
        let timeout = Duration::from_secs(claim.timeout_s);
912
        let mut outcome = if syscall_tier {
913
            let group = format!("job-{}", claim.job_id);
914
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
915
        } else {
916
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
917
        };
918
        if outcome.cancelled {
919
            return Ok(cancelled());
920
        }
921
        // The receipt says what a cached disk started from.
922
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
923
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
924
        for (i, key, how) in &cached {
925
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
926
        }
927
        // A passing job's artifacts, read from its disks and uploaded; a
928
        // declared artifact that cannot be kept fails the job. A failing
929
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
930
        // its receipt, and one it cannot keep is only noted.
931
        let mut outputs = vec![];
932
        let mut output_error = None;
933
        let passed = outcome.exit_code == Some(0);
934
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
935
        for o in wanted {
936
            if o.kind == flow_core::OutputKind::Report {
937
                let read = if syscall_tier {
938
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
939
                    crate::outputs::read_report_dir(&dir, &inner)
940
                } else {
941
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
942
                    crate::outputs::read_report(&image, &inner, &scratch)
943
                };
944
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
945
                match kept {
946
                    Ok((blob, files, bytes)) => {
947
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
948
                        let hex = blob.to_hex();
949
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
950
                        if passed {
951
                            outputs.push(blob.0);
952
                        }
953
                    }
954
                    Err(e) if passed => {
955
                        output_error = Some(format!("report {}: {e:#}", o.name));
956
                        break;
957
                    }
958
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
959
                }
960
                continue;
961
            }
962
            let read = if syscall_tier {
963
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
964
                crate::outputs::read_dir_file(&dir, &inner)
965
            } else {
966
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
967
                crate::outputs::read(&image, &inner, &scratch)
968
            };
969
            let kept = read.and_then(|bytes| {
970
                let blob = Repo::blob_ref(&bytes);
971
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
972
                if named != blob.to_hex() {
973
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
974
                }
975
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
976
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
977
                Ok(blob.0)
978
            });
979
            match kept {
980
                Ok(b) if passed => outputs.push(b),
981
                Ok(_) => {}
982
                Err(e) if passed => {
983
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
984
                    break;
985
                }
986
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
987
            }
988
        }
989
        if let Some(e) = &output_error {
990
            note(&mut sink, e.clone());
991
        }
992
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
993
            job_name: claim.job.name.clone(),
994
            pipeline_hash: hex32(&claim.pipeline_hash),
995
            scope_tree_hash: scope_hash,
996
            inputs: vec![],
997
            environment: outcome.environment.hash(),
998
            outputs,
999
            exit_code: code as u32,
1000
            duration_ms: outcome.duration_ms,
1001
        });
1002
        let signed_receipt = match &receipt {
1003
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
1004
            None => None,
1005
        };
1006
        // Measured before a cached disk leaves the scratch directory.
1007
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1008
        // A passing job's cached disks are kept; a failing job's are
1009
        // thrown away, so nothing it wrote reaches a later job.
1010
        let kept = passed && output_error.is_none();
1011
        for (i, key, _) in &cached {
1012
            let disk = crate::cache::disk_path(form, &scratch, *i);
1013
            if let Err(e) = pool.settle(&cache_ctx, disk_mounts[*i], key, &disk, &sources, kept) {
1014
                note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" }));
1015
            }
1016
        }
1017
        Ok(Finish {
1018
            exit_code: outcome.exit_code,
1019
            duration_ms: started.elapsed().as_millis() as u64,
1020
            receipt,
1021
            signed_receipt,
1022
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
1023
            // What the job took (oxygenflow#41): its disks are sparse files
1024
            // in the scratch directory, so their blocks now are their peak.
1025
            peak_disk_mib,
1026
            peak_memory_mib: outcome.peak_memory_mib,
1027
        })
1028
    })();
1029
    let _ = workspace::remove_tree(&scratch);
1030
    sink.flush();
1031
    result
1032
}
1033
1034
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
1035
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
1036
/// the claim, so the runner vouches only for what it fetched.
1037
/// Upload a report's files, then its manifest under the report's name, and
1038
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
1039
/// the manifest's blob, the file count and their total size.
1040
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
1041
    let mut listed = Vec::with_capacity(files.len());
1042
    let mut total = 0u64;
1043
    for (path, bytes) in files {
1044
        let blob = Repo::blob_ref(bytes).to_hex();
1045
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
1046
        if named != blob {
1047
            bail!("the hub kept {path} as {named}, not {blob}");
1048
        }
1049
        total += bytes.len() as u64;
1050
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
1051
    }
1052
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
1053
    let blob = Repo::blob_ref(&manifest);
1054
    let named = hub.artifact(job, name, &manifest)?;
1055
    if named != blob.to_hex() {
1056
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
1057
    }
1058
    Ok((blob, files.len(), total))
1059
}
1060
1061
fn sign_receipt(
1062
    repo: &Repo,
1063
    heads: &[ChangeHash],
1064
    cfg: &RunnerConfig,
1065
    key: &RunnerKey,
1066
    claim: &Claim,
1067
    receipt: &Receipt,
1068
) -> Result<oxy_core::receipt::SignedReceipt> {
1069
    let state = state_of(heads);
1070
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1071
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
1072
}
1073
1074
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
1075
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1076
    use oxy_core::hash::{hash_object, ObjectType};
1077
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
1078
}
1079
1080
fn hex32(s: &str) -> [u8; 32] {
1081
    let mut out = [0; 32];
1082
    if let Ok(v) = hex::decode(s) {
1083
        if v.len() == 32 {
1084
            out.copy_from_slice(&v);
1085
        }
1086
    }
1087
    out
1088
}
1089
1090
/// Whether a network error is worth another try: DNS, a refused or reset
1091
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
1092
/// hub restarts (oxygenhub#111).
1093
fn transient(msg: &str) -> bool {
1094
    let m = msg.to_lowercase();
1095
    [
1096
        "dns failed",
1097
        "name resolution",
1098
        "connection refused",
1099
        "connection reset",
1100
        "timed out",
1101
        "timeout",
1102
        "http 502",
1103
        "http 503",
1104
        "http 504",
1105
        "broken pipe",
1106
        "unexpected eof",
1107
    ]
1108
    .iter()
1109
    .any(|k| m.contains(k))
1110
}
1111
1112
/// Run `f` again after a transient network error, waiting `waits` between
1113
/// tries (about a minute in all), and give up on any other error at once.
1114
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
1115
    let mut tries = waits.iter();
1116
    loop {
1117
        match f() {
1118
            Ok(v) => return Ok(v),
1119
            Err(e) => match tries.next() {
1120
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
1121
                _ => return Err(e),
1122
            },
1123
        }
1124
    }
1125
}
1126
1127
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
1128
const FETCH_WAITS: [Duration; 5] =
1129
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
1130
1131
/// The runner's copy of a repository, fetched up to date with the job's token.
1132
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
1133
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
1134
}
1135
1136
/// The runner's copy of `repo`, cloned or fetched with `token`.
1137
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
1138
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
1139
    let set_remote = |repo: &mut Repo| -> Result<()> {
1140
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
1141
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
1142
        if let Some(ca) = &cfg.ca {
1143
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
1144
        }
1145
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
1146
    };
1147
    if dir.join(".oxy").is_dir() {
1148
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
1149
        set_remote(&mut repo)?;
1150
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
1151
        return Ok(repo);
1152
    }
1153
    // First job for this repository: clone with the job's token, which goes
1154
    // through the process environment, so one clone at a time.
1155
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
1156
    std::env::set_var("OXY_TOKEN", token);
1157
    if let Some(ca) = &cfg.ca {
1158
        std::env::set_var("OXY_CA", ca);
1159
    }
1160
    let cloned = with_retries(&FETCH_WAITS, || {
1161
        let _ = std::fs::remove_dir_all(&dir);
1162
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
1163
    });
1164
    std::env::remove_var("OXY_TOKEN");
1165
    std::env::remove_var("OXY_CA");
1166
    let mut repo = cloned.map_err(|e| {
1167
        let _ = std::fs::remove_dir_all(&dir);
1168
        anyhow::anyhow!("clone {}: {e}", clone_url)
1169
    })?;
1170
    set_remote(&mut repo)?;
1171
    Ok(repo)
1172
}
1173
1174
#[cfg(test)]
1175
mod tests {
1176
    use super::*;
1177
1178
    #[test]
1179
    fn a_runner_names_what_stops_a_sandbox() {
1180
        let dir = tempfile::tempdir().unwrap();
1181
        let home = Home::locate(Some(dir.path())).unwrap();
1182
        let bytes = b"not really ext4";
1183
        let hash = blake3::hash(bytes).to_hex().to_string();
1184
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
1185
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
1186
        }))
1187
        .unwrap();
1188
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
1189
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
1190
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
1191
        sb.rootfs = hash.clone();
1192
        assert_eq!(problem(&sb, Some(4096)), None);
1193
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
1194
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
1195
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
1196
        sb.arch = Some("aarch64".into());
1197
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
1198
        sb.arch = None;
1199
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
1200
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
1201
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
1202
    }
1203
1204
    #[test]
1205
    fn transient_network_errors_are_tried_again_and_others_are_not() {
1206
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
1207
        assert!(transient("HTTP 503: the hub is restarting"));
1208
        assert!(!transient("HTTP 404: no such repository"));
1209
        let mut n = 0;
1210
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
1211
            n += 1;
1212
            if n < 3 {
1213
                Err(anyhow::anyhow!("connection refused"))
1214
            } else {
1215
                Ok(7)
1216
            }
1217
        });
1218
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
1219
        let mut m = 0;
1220
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
1221
            m += 1;
1222
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
1223
        });
1224
        assert!(got.is_err());
1225
        assert_eq!(m, 1, "a refusal is not tried again");
1226
        let mut k = 0;
1227
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
1228
            k += 1;
1229
            Err(anyhow::anyhow!("timed out"))
1230
        });
1231
        assert!(got.is_err());
1232
        assert_eq!(k, 3, "gives up after the last wait");
1233
    }
1234
1235
    /// A hub that answers each request with the next of `codes`, counting them.
1236
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1237
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
1238
    }
1239
1240
    /// A hub at `listener` answering each request with the next of `codes`.
1241
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1242
        use std::io::{Read, Write};
1243
        let addr = listener.local_addr().unwrap();
1244
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1245
        let count = seen.clone();
1246
        std::thread::spawn(move || {
1247
            for code in codes {
1248
                let (mut s, _) = listener.accept().unwrap();
1249
                let mut buf = [0u8; 8192];
1250
                let _ = s.read(&mut buf);
1251
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1252
                let reason = if code == 204 { "No Content" } else { "Error" };
1253
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
1254
            }
1255
        });
1256
        (format!("http://{addr}"), seen)
1257
    }
1258
1259
    /// A hub that answers one request with `body` (status 200) and hands
1260
    /// back what it was sent.
1261
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
1262
        use std::io::{Read, Write};
1263
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1264
        let addr = listener.local_addr().unwrap();
1265
        let (tx, rx) = std::sync::mpsc::channel();
1266
        std::thread::spawn(move || {
1267
            let (mut s, _) = listener.accept().unwrap();
1268
            let mut req = Vec::new();
1269
            let mut buf = [0u8; 65536];
1270
            loop {
1271
                let n = s.read(&mut buf).unwrap_or(0);
1272
                if n == 0 {
1273
                    break;
1274
                }
1275
                req.extend_from_slice(&buf[..n]);
1276
                let text = String::from_utf8_lossy(&req);
1277
                if let Some(end) = text.find("\r\n\r\n") {
1278
                    let len = text[..end]
1279
                        .lines()
1280
                        .find_map(|l| {
1281
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1282
                        })
1283
                        .unwrap_or(0);
1284
                    if req.len() >= end + 4 + len {
1285
                        break;
1286
                    }
1287
                }
1288
            }
1289
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
1290
            let _ = write!(
1291
                s,
1292
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1293
                body.len()
1294
            );
1295
        });
1296
        (format!("http://{addr}"), rx)
1297
    }
1298
1299
    #[test]
1300
    fn registering_binds_the_key_and_keeps_the_credential_private() {
1301
        use std::os::unix::fs::PermissionsExt;
1302
        let dir = tempfile::tempdir().unwrap();
1303
        let home = Home::locate(Some(dir.path())).unwrap();
1304
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
1305
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
1306
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
1307
        let req = sent.recv().unwrap();
1308
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
1309
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
1310
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
1311
        let key = home.runner_key("plex").unwrap();
1312
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
1313
        let path = dir.path().join(crate::home::RUNNER_TOML);
1314
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1315
        // The runner then starts from runner.toml.
1316
        let again = Home::locate(Some(dir.path())).unwrap();
1317
        let cfg = again.config.runner.unwrap();
1318
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
1319
        assert_eq!(cfg.labels, ["gpu"]);
1320
    }
1321
1322
    fn hub_at(base: String) -> Hub {
1323
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
1324
    }
1325
1326
    /// oxygenhub#71: a job's result is offered until the hub takes it,
1327
    /// through server errors, and a refusal ends the trying at once.
1328
    #[test]
1329
    fn a_result_is_reported_until_the_hub_takes_it() {
1330
        let f = Finish {
1331
            exit_code: Some(0),
1332
            duration_ms: 1,
1333
            receipt: None,
1334
            signed_receipt: None,
1335
            error: None,
1336
            peak_disk_mib: None,
1337
            peak_memory_mib: None,
1338
        };
1339
        let quick = Duration::from_millis(10);
1340
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
1341
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
1342
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
1343
        let (base, seen) = fake_hub(vec![409]);
1344
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
1345
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1346
    }
1347
1348
    #[test]
1349
    fn server_errors_are_tried_again_and_client_errors_are_not() {
1350
        let quick = [Duration::from_millis(10); 3];
1351
        let (base, seen) = fake_hub(vec![500, 503, 204]);
1352
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
1353
        assert!(r.is_none());
1354
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
1355
1356
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
1357
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
1358
        assert!(e.contains("HTTP 500"), "{e}");
1359
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
1360
1361
        let (base, seen) = fake_hub(vec![401]);
1362
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
1363
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1364
    }
1365
1366
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
1367
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
1368
    #[test]
1369
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
1370
        use microvm::Sink;
1371
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1372
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
1373
        let (base, seen) = fake_hub(vec![204]);
1374
        let hub = hub_at(base);
1375
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1376
        assert!(!sink.cancelled());
1377
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
1378
        assert_eq!(n(&seen), 1);
1379
1380
        let (base, seen) = fake_hub(vec![409]);
1381
        let hub = hub_at(base);
1382
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1383
        assert!(sink.cancelled());
1384
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
1385
        sink.flush();
1386
        assert!(sink.cancelled());
1387
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1388
    }
1389
1390
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
1391
    /// it) costs a job's uploads a wait, not the upload, and costs its log
1392
    /// no lines.
1393
    #[test]
1394
    fn a_job_outlasts_a_hub_restart() {
1395
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1396
        // A port nobody listens on yet: the hub is restarting.
1397
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1398
        let base = format!("http://127.0.0.1:{port}");
1399
        let hub = hub_at(base.clone());
1400
1401
        // Log lines sent while it is away are kept, and go when it is back.
1402
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1403
        for i in 0..450 {
1404
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
1405
        }
1406
        sink.send();
1407
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
1408
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
1409
        sink.send();
1410
        assert!(sink.buf.is_empty() && !sink.gone);
1411
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
1412
1413
        // An upload waits for the hub to come back, through refused
1414
        // connections and server errors alike.
1415
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1416
        let url = format!("http://127.0.0.1:{port}/x");
1417
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
1418
        let later = std::thread::spawn(move || {
1419
            std::thread::sleep(Duration::from_millis(150));
1420
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
1421
        });
1422
        let quick = [Duration::from_millis(50); 20];
1423
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
1424
        assert_eq!(r.status(), 204);
1425
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
1426
        // Without `connection`, a hub that cannot be reached ends it at once.
1427
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1428
        let url = format!("http://127.0.0.1:{port}/x");
1429
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
1430
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
1431
    }
1432
1433
    /// oxygenflow#50: the first stop drains (the second, not called here,
1434
    /// exits at once).
1435
    #[test]
1436
    fn a_stop_drains_the_runner() {
1437
        assert!(!stopping());
1438
        on_stop(libc::SIGTERM);
1439
        assert!(stopping(), "told to stop, it claims nothing more");
1440
    }
1441
1442
    /// Lines kept for a hub that stays away are bounded, saying how many went.
1443
    #[test]
1444
    fn unsent_log_lines_are_bounded() {
1445
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1446
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
1447
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1448
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
1449
        sink.send();
1450
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
1451
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
1452
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
1453
    }
1454
1455
    #[test]
1456
    fn the_budget_admits_jobs_that_fit() {
1457
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1458
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1459
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1460
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1461
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1462
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1463
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1464
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1465
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1466
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1467
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1468
    }
1469
}