OXYGENHUB
# b11e30f5031d oxygenflow / crates / flow / src / runner.rs
44.7 KiB · Rust · last changed A runner declines a job it cannot run 1 month 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 { exit_code: None, duration_ms: 0, receipt: None, signed_receipt: None, error: Some(CANCELLED.into()) }
43
}
44
45
pub struct Hub {
46
    base: String,
47
    token: String,
48
    agent: ureq::Agent,
49
}
50
51
impl Hub {
52
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
53
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
54
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
55
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
56
    }
57
58
    /// POST to the hub. A server error (5xx) means nothing was committed,
59
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
60
    /// busy hub costs a job a few seconds, not its log lines or its claim.
61
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
62
        self.post_with(path, body, &RETRY_DELAYS)
63
    }
64
65
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
66
        let url = format!("{}/api/v1{path}", self.base);
67
        let mut delays = delays.iter();
68
        loop {
69
            match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
70
                Ok(r) if r.status() == 204 => return Ok(None),
71
                Ok(r) => return Ok(Some(r)),
72
                Err(ureq::Error::Status(code, r)) => {
73
                    let text = r.into_string().unwrap_or_default();
74
                    match delays.next() {
75
                        Some(d) if code >= 500 => std::thread::sleep(*d),
76
                        _ if code < 500 => return Err(anyhow::Error::new(Refused { code, text }).context(url)),
77
                        _ => bail!("{url}: HTTP {code}: {text}"),
78
                    }
79
                }
80
                Err(e) => bail!("{url}: {e}"),
81
            }
82
        }
83
    }
84
85
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
86
        match self.post("/flow/claim", req)? {
87
            Some(r) => Ok(Some(r.into_json()?)),
88
            None => Ok(None),
89
        }
90
    }
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
98
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
99
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
100
        Ok(())
101
    }
102
103
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
104
    /// a blob of the job's repository and answers with its ObjectRef.
105
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
106
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
107
        let mut delays = RETRY_DELAYS.iter();
108
        loop {
109
            let req = self
110
                .agent
111
                .post(&url)
112
                .timeout(Duration::from_secs(600))
113
                .set("Authorization", &format!("Bearer {}", self.token))
114
                .set("Content-Type", "application/octet-stream");
115
            match req.send_bytes(bytes) {
116
                Ok(r) => {
117
                    let v: serde_json::Value = r.into_json()?;
118
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
119
                }
120
                Err(ureq::Error::Status(code, r)) => {
121
                    let text = r.into_string().unwrap_or_default();
122
                    match delays.next() {
123
                        Some(d) if code >= 500 => std::thread::sleep(*d),
124
                        _ => bail!("{url}: HTTP {code}: {text}"),
125
                    }
126
                }
127
                Err(e) => bail!("{url}: {e}"),
128
            }
129
        }
130
    }
131
132
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
133
    /// cannot be reached or fails (5xx), try again after `first_wait`,
134
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
135
    /// already, say) is final. A result lost here leaves the job running
136
    /// on the hub until its time limit.
137
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
138
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
139
        let deadline = std::time::Instant::now() + patience;
140
        let mut wait = first_wait;
141
        loop {
142
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
143
                Ok(_) => return Ok(()),
144
                Err(ureq::Error::Status(code, r)) if code < 500 => {
145
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
146
                }
147
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
148
                Err(e) => e.to_string(),
149
            };
150
            if std::time::Instant::now() + wait > deadline {
151
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
152
            }
153
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
154
            std::thread::sleep(wait);
155
            wait = (wait * 2).min(Duration::from_secs(60));
156
        }
157
    }
158
}
159
160
/// How long a runner keeps trying to report a job's end.
161
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
162
163
/// Where a job's result waits until the hub has it, so a runner that
164
/// restarts still reports it.
165
fn pending_path(home: &Home, job: i64) -> PathBuf {
166
    home.root.join("pending").join(format!("{job}.json"))
167
}
168
169
/// Report `f`, keeping it on disk until the hub has it.
170
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
171
    let path = pending_path(home, job);
172
    let kept = std::fs::create_dir_all(path.parent().unwrap())
173
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
174
        .is_ok();
175
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
176
        Ok(()) => {}
177
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
178
    }
179
    if kept {
180
        let _ = std::fs::remove_file(&path);
181
    }
182
}
183
184
/// Results a previous run of the runner could not report, to send again.
185
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
186
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
187
        return Vec::new();
188
    };
189
    dir.filter_map(|e| {
190
        let p = e.ok()?.path();
191
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
192
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
193
        Some((job, f))
194
    })
195
    .collect()
196
}
197
198
/// `flow runner register`: exchange a one-time registration token from
199
/// the hub for this runner's credential, binding the runner's signing key,
200
/// and keep hub, credential and name in runner.toml.
201
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
202
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
203
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
204
    }
205
    let key = home.runner_key(name)?;
206
    let hub = hub.trim_end_matches('/').to_string();
207
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
208
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
209
    let req = Register {
210
        token: token.trim().to_string(),
211
        name: name.to_string(),
212
        identity: hex::encode(key.identity.encode()),
213
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
214
    };
215
    let url = format!("{hub}/api/v1/flow/runners/register");
216
    let got: Registered = match agent.post(&url).send_json(&req) {
217
        Ok(r) => r.into_json()?,
218
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
219
        Err(e) => bail!("{url}: {e}"),
220
    };
221
    let mut text = format!(
222
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
223
        got.credential, got.name
224
    );
225
    if let Some(ca) = &ca {
226
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
227
    }
228
    if !labels.is_empty() {
229
        text.push_str(&format!("labels = {labels:?}\n"));
230
    }
231
    let path = home.root.join(crate::home::RUNNER_TOML);
232
    {
233
        use std::io::Write;
234
        use std::os::unix::fs::OpenOptionsExt;
235
        let tmp = path.with_extension("toml.tmp");
236
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
237
        f.write_all(text.as_bytes())?;
238
        f.sync_all()?;
239
        std::fs::rename(&tmp, &path)?;
240
    }
241
    Ok(got)
242
}
243
244
/// Waits before trying a hub request again after a server error.
245
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
246
247
/// What the runner may run at once (flow.toml `[runner]`): at most
248
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
249
#[derive(Debug, Default, PartialEq, Eq)]
250
pub struct Budget {
251
    pub max_jobs: usize,
252
    pub memory_mib: Option<u32>,
253
    pub cpus: Option<u32>,
254
}
255
256
/// What is in use now.
257
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
258
pub struct Used {
259
    pub jobs: usize,
260
    pub memory_mib: u32,
261
    pub cpus: u32,
262
}
263
264
impl Budget {
265
    pub fn of(cfg: &RunnerConfig) -> Budget {
266
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
267
    }
268
269
    /// The room to ask the hub for, or `None` when no job more can start.
270
    /// A lone job may use the whole budget even if it asks for more, so a
271
    /// large sandbox is never starved.
272
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
273
        if used.jobs >= self.max_jobs {
274
            return None;
275
        }
276
        if used.jobs == 0 {
277
            return Some((None, None));
278
        }
279
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
280
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
281
        if mem == Some(0) || cpus == Some(0) {
282
            return None;
283
        }
284
        Some((mem, cpus))
285
    }
286
}
287
288
/// The isolation tiers this machine can honour now (spec §11.1): the
289
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
290
/// flow.toml pins Firecracker; the syscall tier when this binary may use
291
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
292
/// `Err` says why there are none; the reasons for missing tiers come back
293
/// with the ones there are.
294
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
295
    let mut have = Vec::new();
296
    let mut missing = Vec::new();
297
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
298
    match (kvm, home.config.microvm.is_some()) {
299
        (Ok(_), true) => have.push("microvm".to_string()),
300
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
301
        (Err(e), _) => missing.push(format!(
302
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
303
        )),
304
    }
305
    match crate::syscall::available() {
306
        Ok(()) => have.push("syscall".to_string()),
307
        Err(e) => missing.push(format!("syscall: {e}")),
308
    }
309
    if have.is_empty() {
310
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
311
    }
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)
368
}
369
370
pub fn serve(home: &Home) -> Result<()> {
371
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
372
    // A runner offers only what it can run: a machine without KVM claims
373
    // nothing rather than taking MicroVM jobs and failing them (#36).
374
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
375
    for why in &missing {
376
        eprintln!("flow runner: not offering {why}");
377
    }
378
    let hub = Hub::new(&cfg)?;
379
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
380
    let budget = Budget::of(&cfg);
381
    let key = home.runner_key(&name)?;
382
    eprintln!(
383
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
384
        cfg.hub,
385
        budget.max_jobs,
386
        runtimes.join(", "),
387
        key.identity.hash().to_hex()
388
    );
389
    let used = std::sync::Mutex::new(Used::default());
390
    let mut idle = Duration::from_secs(2);
391
    let pending = report_pending(home);
392
    // The jobs held now, told to the hub at each claim so it can end the
393
    // ones this runner lost: a job is held from its claim until its result
394
    // is reported.
395
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
396
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
397
    std::thread::scope(|scope| {
398
        for (job, f) in pending {
399
            let (hub, held) = (&hub, &held);
400
            scope.spawn(move || {
401
                eprintln!("job {job}: reporting the result kept from before the restart");
402
                report_finish(home, hub, job, &f);
403
                held.lock().unwrap().remove(&job);
404
            });
405
        }
406
        loop {
407
            let room = budget.room(*used.lock().unwrap());
408
            let Some((free_memory_mib, free_cpus)) = room else {
409
                std::thread::sleep(Duration::from_millis(500));
410
                continue;
411
            };
412
            let req = ClaimRequest {
413
                runner: name.clone(),
414
                runtimes: runtimes.clone(),
415
                free_memory_mib,
416
                free_cpus,
417
                info: Some(RunnerInfo {
418
                    version: env!("OXY_VERSION_LONG").into(),
419
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
420
                }),
421
                // Read each time: an image imported while the runner runs counts.
422
                images: Some(home.images()),
423
                image_archs: Some(home.image_archs()),
424
                holding: Some(held.lock().unwrap().iter().copied().collect()),
425
            };
426
            match hub.claim(&req) {
427
                Ok(Some(claim)) => {
428
                    idle = Duration::from_secs(2);
429
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
430
                    {
431
                        let mut u = used.lock().unwrap();
432
                        u.jobs += 1;
433
                        u.memory_mib += mem;
434
                        u.cpus += cpus;
435
                    }
436
                    held.lock().unwrap().insert(claim.job_id);
437
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
438
                    scope.spawn(move || {
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
                        }
452
                        eprintln!(
453
                            "job {id}: {} {} on {}",
454
                            claim.repo,
455
                            claim.job.name,
456
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
457
                        );
458
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
459
                            exit_code: None,
460
                            duration_ms: 0,
461
                            receipt: None,
462
                            signed_receipt: None,
463
                            error: Some(format!("{e:#}")),
464
                        });
465
                        eprintln!(
466
                            "job {id}: exit {:?}{}",
467
                            finish.exit_code,
468
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
469
                        );
470
                        if finish.error.as_deref() != Some(CANCELLED) {
471
                            report_finish(home, hub, id, &finish);
472
                        }
473
                        held.lock().unwrap().remove(&id);
474
                        let mut u = used.lock().unwrap();
475
                        u.jobs -= 1;
476
                        u.memory_mib -= mem;
477
                        u.cpus -= cpus;
478
                    });
479
                }
480
                Ok(None) => std::thread::sleep(idle),
481
                Err(e) => {
482
                    eprintln!("claim: {e:#}");
483
                    std::thread::sleep(idle);
484
                    idle = (idle * 2).min(Duration::from_secs(60));
485
                }
486
            }
487
        }
488
    })
489
}
490
491
/// Batches records to the hub every second (or every 200 lines). A quiet
492
/// job's sink still asks the hub every few seconds whether the job runs,
493
/// with an empty batch; once the hub answers 409 the job is not wanted.
494
struct LogSink<'a> {
495
    hub: &'a Hub,
496
    job: i64,
497
    buf: Vec<Record>,
498
    last: Instant,
499
    gone: bool,
500
}
501
502
/// How often a quiet job asks whether it was cancelled.
503
const HEARTBEAT: Duration = Duration::from_secs(5);
504
505
impl microvm::Sink for LogSink<'_> {
506
    fn record(&mut self, r: Record) {
507
        self.push(r)
508
    }
509
510
    fn cancelled(&mut self) -> bool {
511
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
512
            self.send();
513
        }
514
        self.gone
515
    }
516
}
517
518
impl LogSink<'_> {
519
    fn push(&mut self, mut r: Record) {
520
        if self.gone {
521
            return;
522
        }
523
        if r.text.len() > MAX_RECORD_TEXT {
524
            let mut cut = MAX_RECORD_TEXT;
525
            while !r.text.is_char_boundary(cut) {
526
                cut -= 1;
527
            }
528
            r.text.truncate(cut);
529
        }
530
        self.buf.push(r);
531
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
532
            self.flush();
533
        }
534
    }
535
536
    fn flush(&mut self) {
537
        if !self.buf.is_empty() {
538
            self.send();
539
        }
540
        self.last = Instant::now();
541
    }
542
543
    /// Send what is buffered, even nothing.
544
    fn send(&mut self) {
545
        if self.gone {
546
            return;
547
        }
548
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
549
            Ok(()) => {}
550
            Err(e) if job_gone(&e) => self.gone = true,
551
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
552
        }
553
        self.last = Instant::now();
554
    }
555
}
556
557
/// Held while a job's workspace is prepared (see `run_claim`).
558
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
559
560
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
561
    let started = Instant::now();
562
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
563
    let note =
564
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
565
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
566
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
567
    // Preparing a workspace (the cached repositories, the export, the
568
    // image) is one job at a time: fetches into a cached repository must not
569
    // overlap, and the first clone passes its token through the process
570
    // environment. Only the microVMs run side by side.
571
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
572
    let repo = cached_repo(home, cfg, claim)?;
573
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
574
    let _ = std::fs::remove_dir_all(&scratch);
575
    std::fs::create_dir_all(scratch.join("tree"))?;
576
    let result = (|| -> Result<Finish> {
577
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
578
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
579
        // workspace and of the scope hash.
580
        for sub in &claim.subrepos {
581
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
582
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
583
            }
584
            note(
585
                &mut sink,
586
                format!(
587
                    "fetching subrepo {} at {} for {}",
588
                    sub.repo,
589
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
590
                    sub.path
591
                ),
592
            );
593
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
594
            let sub_heads: Vec<ChangeHash> =
595
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
596
            let dir = scratch.join("tree").join(&sub.path);
597
            std::fs::create_dir_all(&dir)?;
598
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
599
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
600
        }
601
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
602
        // The syscall tier runs on the tree itself; a microVM gets an image.
603
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
604
        if !syscall_tier {
605
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
606
            std::fs::remove_dir_all(scratch.join("tree"))?;
607
        }
608
        preparing.take();
609
        // Cancelled while it was being prepared: no sandbox.
610
        if microvm::Sink::cancelled(&mut sink) {
611
            return Ok(cancelled());
612
        }
613
        note(
614
            &mut sink,
615
            format!(
616
                "{}: {} {}, {} MiB, rootfs {}",
617
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
618
                claim.sandbox.cpus,
619
                if syscall_tier { "CPU" } else { "vCPU" },
620
                claim.sandbox.memory_mib,
621
                &claim.sandbox.rootfs[..12]
622
            ),
623
        );
624
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
625
        let timeout = Duration::from_secs(claim.timeout_s);
626
        let outcome = if syscall_tier {
627
            let group = format!("job-{}", claim.job_id);
628
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
629
        } else {
630
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
631
        };
632
        if outcome.cancelled {
633
            return Ok(cancelled());
634
        }
635
        // A passing job's artifacts, read from its disks and uploaded; a
636
        // declared artifact that cannot be kept fails the job.
637
        let mut outputs = vec![];
638
        let mut output_error = None;
639
        if outcome.exit_code == Some(0) {
640
            for o in &claim.job.outputs {
641
                let read = if syscall_tier {
642
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
643
                    crate::outputs::read_dir_file(&dir, &inner)
644
                } else {
645
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
646
                    crate::outputs::read(&image, &inner, &scratch)
647
                };
648
                let kept = read.and_then(|bytes| {
649
                    let blob = Repo::blob_ref(&bytes);
650
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
651
                    if named != blob.to_hex() {
652
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
653
                    }
654
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
655
                    Ok(blob.0)
656
                });
657
                match kept {
658
                    Ok(b) => outputs.push(b),
659
                    Err(e) => {
660
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
661
                        break;
662
                    }
663
                }
664
            }
665
        }
666
        if let Some(e) = &output_error {
667
            note(&mut sink, e.clone());
668
        }
669
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
670
            job_name: claim.job.name.clone(),
671
            pipeline_hash: hex32(&claim.pipeline_hash),
672
            scope_tree_hash: scope_hash,
673
            inputs: vec![],
674
            environment: outcome.environment.hash(),
675
            outputs,
676
            exit_code: code as u32,
677
            duration_ms: outcome.duration_ms,
678
        });
679
        let signed_receipt = match &receipt {
680
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
681
            None => None,
682
        };
683
        Ok(Finish {
684
            exit_code: outcome.exit_code,
685
            duration_ms: started.elapsed().as_millis() as u64,
686
            receipt,
687
            signed_receipt,
688
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
689
        })
690
    })();
691
    let _ = workspace::remove_tree(&scratch);
692
    sink.flush();
693
    result
694
}
695
696
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
697
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
698
/// the claim, so the runner vouches only for what it fetched.
699
fn sign_receipt(
700
    repo: &Repo,
701
    heads: &[ChangeHash],
702
    cfg: &RunnerConfig,
703
    key: &RunnerKey,
704
    claim: &Claim,
705
    receipt: &Receipt,
706
) -> Result<oxy_core::receipt::SignedReceipt> {
707
    let state = state_of(heads);
708
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
709
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
710
}
711
712
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
713
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
714
    use oxy_core::hash::{hash_object, ObjectType};
715
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
716
}
717
718
fn hex32(s: &str) -> [u8; 32] {
719
    let mut out = [0; 32];
720
    if let Ok(v) = hex::decode(s) {
721
        if v.len() == 32 {
722
            out.copy_from_slice(&v);
723
        }
724
    }
725
    out
726
}
727
728
/// Whether a network error is worth another try: DNS, a refused or reset
729
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
730
/// hub restarts (oxygenhub#111).
731
fn transient(msg: &str) -> bool {
732
    let m = msg.to_lowercase();
733
    [
734
        "dns failed",
735
        "name resolution",
736
        "connection refused",
737
        "connection reset",
738
        "timed out",
739
        "timeout",
740
        "http 502",
741
        "http 503",
742
        "http 504",
743
        "broken pipe",
744
        "unexpected eof",
745
    ]
746
    .iter()
747
    .any(|k| m.contains(k))
748
}
749
750
/// Run `f` again after a transient network error, waiting `waits` between
751
/// tries (about a minute in all), and give up on any other error at once.
752
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
753
    let mut tries = waits.iter();
754
    loop {
755
        match f() {
756
            Ok(v) => return Ok(v),
757
            Err(e) => match tries.next() {
758
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
759
                _ => return Err(e),
760
            },
761
        }
762
    }
763
}
764
765
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
766
const FETCH_WAITS: [Duration; 5] =
767
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
768
769
/// The runner's copy of a repository, fetched up to date with the job's token.
770
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
771
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
772
}
773
774
/// The runner's copy of `repo`, cloned or fetched with `token`.
775
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
776
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
777
    let set_remote = |repo: &mut Repo| -> Result<()> {
778
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
779
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
780
        if let Some(ca) = &cfg.ca {
781
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
782
        }
783
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
784
    };
785
    if dir.join(".oxy").is_dir() {
786
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
787
        set_remote(&mut repo)?;
788
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
789
        return Ok(repo);
790
    }
791
    // First job for this repository: clone with the job's token.
792
    std::env::set_var("OXY_TOKEN", token);
793
    if let Some(ca) = &cfg.ca {
794
        std::env::set_var("OXY_CA", ca);
795
    }
796
    let cloned = with_retries(&FETCH_WAITS, || {
797
        let _ = std::fs::remove_dir_all(&dir);
798
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
799
    });
800
    std::env::remove_var("OXY_TOKEN");
801
    std::env::remove_var("OXY_CA");
802
    let mut repo = cloned.map_err(|e| {
803
        let _ = std::fs::remove_dir_all(&dir);
804
        anyhow::anyhow!("clone {}: {e}", clone_url)
805
    })?;
806
    set_remote(&mut repo)?;
807
    Ok(repo)
808
}
809
810
#[cfg(test)]
811
mod tests {
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
    }
839
840
    #[test]
841
    fn transient_network_errors_are_tried_again_and_others_are_not() {
842
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
843
        assert!(transient("HTTP 503: the hub is restarting"));
844
        assert!(!transient("HTTP 404: no such repository"));
845
        let mut n = 0;
846
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
847
            n += 1;
848
            if n < 3 {
849
                Err(anyhow::anyhow!("connection refused"))
850
            } else {
851
                Ok(7)
852
            }
853
        });
854
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
855
        let mut m = 0;
856
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
857
            m += 1;
858
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
859
        });
860
        assert!(got.is_err());
861
        assert_eq!(m, 1, "a refusal is not tried again");
862
        let mut k = 0;
863
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
864
            k += 1;
865
            Err(anyhow::anyhow!("timed out"))
866
        });
867
        assert!(got.is_err());
868
        assert_eq!(k, 3, "gives up after the last wait");
869
    }
870
871
    /// A hub that answers each request with the next of `codes`, counting them.
872
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
873
        use std::io::{Read, Write};
874
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
875
        let addr = listener.local_addr().unwrap();
876
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
877
        let count = seen.clone();
878
        std::thread::spawn(move || {
879
            for code in codes {
880
                let (mut s, _) = listener.accept().unwrap();
881
                let mut buf = [0u8; 8192];
882
                let _ = s.read(&mut buf);
883
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
884
                let reason = if code == 204 { "No Content" } else { "Error" };
885
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
886
            }
887
        });
888
        (format!("http://{addr}"), seen)
889
    }
890
891
    /// A hub that answers one request with `body` (status 200) and hands
892
    /// back what it was sent.
893
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
894
        use std::io::{Read, Write};
895
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
896
        let addr = listener.local_addr().unwrap();
897
        let (tx, rx) = std::sync::mpsc::channel();
898
        std::thread::spawn(move || {
899
            let (mut s, _) = listener.accept().unwrap();
900
            let mut req = Vec::new();
901
            let mut buf = [0u8; 65536];
902
            loop {
903
                let n = s.read(&mut buf).unwrap_or(0);
904
                if n == 0 {
905
                    break;
906
                }
907
                req.extend_from_slice(&buf[..n]);
908
                let text = String::from_utf8_lossy(&req);
909
                if let Some(end) = text.find("\r\n\r\n") {
910
                    let len = text[..end]
911
                        .lines()
912
                        .find_map(|l| {
913
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
914
                        })
915
                        .unwrap_or(0);
916
                    if req.len() >= end + 4 + len {
917
                        break;
918
                    }
919
                }
920
            }
921
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
922
            let _ = write!(
923
                s,
924
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
925
                body.len()
926
            );
927
        });
928
        (format!("http://{addr}"), rx)
929
    }
930
931
    #[test]
932
    fn registering_binds_the_key_and_keeps_the_credential_private() {
933
        use std::os::unix::fs::PermissionsExt;
934
        let dir = tempfile::tempdir().unwrap();
935
        let home = Home::locate(Some(dir.path())).unwrap();
936
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
937
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
938
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
939
        let req = sent.recv().unwrap();
940
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
941
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
942
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
943
        let key = home.runner_key("plex").unwrap();
944
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
945
        let path = dir.path().join(crate::home::RUNNER_TOML);
946
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
947
        // The runner then starts from runner.toml.
948
        let again = Home::locate(Some(dir.path())).unwrap();
949
        let cfg = again.config.runner.unwrap();
950
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
951
        assert_eq!(cfg.labels, ["gpu"]);
952
    }
953
954
    fn hub_at(base: String) -> Hub {
955
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
956
    }
957
958
    /// oxygenhub#71: a job's result is offered until the hub takes it,
959
    /// through server errors, and a refusal ends the trying at once.
960
    #[test]
961
    fn a_result_is_reported_until_the_hub_takes_it() {
962
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
963
        let quick = Duration::from_millis(10);
964
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
965
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
966
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
967
        let (base, seen) = fake_hub(vec![409]);
968
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
969
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
970
    }
971
972
    #[test]
973
    fn server_errors_are_tried_again_and_client_errors_are_not() {
974
        let quick = [Duration::from_millis(10); 3];
975
        let (base, seen) = fake_hub(vec![500, 503, 204]);
976
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
977
        assert!(r.is_none());
978
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
979
980
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
981
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
982
        assert!(e.contains("HTTP 500"), "{e}");
983
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
984
985
        let (base, seen) = fake_hub(vec![401]);
986
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
987
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
988
    }
989
990
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
991
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
992
    #[test]
993
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
994
        use microvm::Sink;
995
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
996
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
997
        let (base, seen) = fake_hub(vec![204]);
998
        let hub = hub_at(base);
999
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1000
        assert!(!sink.cancelled());
1001
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
1002
        assert_eq!(n(&seen), 1);
1003
1004
        let (base, seen) = fake_hub(vec![409]);
1005
        let hub = hub_at(base);
1006
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1007
        assert!(sink.cancelled());
1008
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
1009
        sink.flush();
1010
        assert!(sink.cancelled());
1011
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1012
    }
1013
1014
    #[test]
1015
    fn the_budget_admits_jobs_that_fit() {
1016
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1017
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1018
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1019
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1020
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1021
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1022
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1023
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1024
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1025
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1026
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1027
    }
1028
}