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