OXYGENHUB
# 9a6fac2b40a9 oxygenflow / crates / flow / src / runner.rs
40.1 KiB · Rust · last changed Artifacts: when "always" keeps a failing job's report 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
9a6fac2b Andy McCall ↶ 563
        // declared artifact that cannot be kept fails the job. A failing
564
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
565
        // its receipt, and one it cannot keep is only noted.
a67b97e9 Andy McCall ↶ 566
        let mut outputs = vec![];
567
        let mut output_error = None;
9a6fac2b Andy McCall ↶ 568
        let passed = outcome.exit_code == Some(0);
569
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
570
        for o in wanted {
571
            let read = if syscall_tier {
572
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
573
                crate::outputs::read_dir_file(&dir, &inner)
574
            } else {
575
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
576
                crate::outputs::read(&image, &inner, &scratch)
577
            };
578
            let kept = read.and_then(|bytes| {
579
                let blob = Repo::blob_ref(&bytes);
580
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
581
                if named != blob.to_hex() {
582
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
583
                }
584
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
585
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
586
                Ok(blob.0)
587
            });
588
            match kept {
589
                Ok(b) if passed => outputs.push(b),
590
                Ok(_) => {}
591
                Err(e) if passed => {
592
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
593
                    break;
a67b97e9 Andy McCall ↶ 594
                }
9a6fac2b Andy McCall ↶ 595
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
a67b97e9 Andy McCall ↶ 596
            }
597
        }
598
        if let Some(e) = &output_error {
599
            note(&mut sink, e.clone());
600
        }
601
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
e0918b88 Andy McCall 602
            job_name: claim.job.name.clone(),
603
            pipeline_hash: hex32(&claim.pipeline_hash),
604
            scope_tree_hash: scope_hash,
605
            inputs: vec![],
606
            environment: outcome.environment.hash(),
a67b97e9 Andy McCall ↶ 607
            outputs,
e0918b88 Andy McCall 608
            exit_code: code as u32,
609
            duration_ms: outcome.duration_ms,
610
        });
47c1d64d Andy McCall ↶ 611
        let signed_receipt = match &receipt {
612
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
613
            None => None,
614
        };
e0918b88 Andy McCall 615
        Ok(Finish {
616
            exit_code: outcome.exit_code,
617
            duration_ms: started.elapsed().as_millis() as u64,
618
            receipt,
47c1d64d Andy McCall ↶ 619
            signed_receipt,
a67b97e9 Andy McCall ↶ 620
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
e0918b88 Andy McCall 621
        })
622
    })();
ea56c56e Andy McCall ↶ 623
    let _ = workspace::remove_tree(&scratch);
e0918b88 Andy McCall 624
    sink.flush();
625
    result
47c1d64d Andy McCall ↶ 626
}
627
628
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
629
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
630
/// the claim, so the runner vouches only for what it fetched.
631
fn sign_receipt(
632
    repo: &Repo,
633
    heads: &[ChangeHash],
634
    cfg: &RunnerConfig,
635
    key: &RunnerKey,
636
    claim: &Claim,
637
    receipt: &Receipt,
638
) -> Result<oxy_core::receipt::SignedReceipt> {
639
    let state = state_of(heads);
640
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
641
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
642
}
643
644
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
645
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
646
    use oxy_core::hash::{hash_object, ObjectType};
647
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
e0918b88 Andy McCall 648
}
649
650
fn hex32(s: &str) -> [u8; 32] {
651
    let mut out = [0; 32];
652
    if let Ok(v) = hex::decode(s) {
653
        if v.len() == 32 {
654
            out.copy_from_slice(&v);
655
        }
656
    }
657
    out
658
}
659
f53e6d47 Andy McCall ↶ 660
/// Whether a network error is worth another try: DNS, a refused or reset
661
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
662
/// hub restarts (oxygenhub#111).
663
fn transient(msg: &str) -> bool {
664
    let m = msg.to_lowercase();
665
    [
666
        "dns failed",
667
        "name resolution",
668
        "connection refused",
669
        "connection reset",
670
        "timed out",
671
        "timeout",
672
        "http 502",
673
        "http 503",
674
        "http 504",
675
        "broken pipe",
676
        "unexpected eof",
677
    ]
678
    .iter()
679
    .any(|k| m.contains(k))
680
}
681
682
/// Run `f` again after a transient network error, waiting `waits` between
683
/// tries (about a minute in all), and give up on any other error at once.
684
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
685
    let mut tries = waits.iter();
686
    loop {
687
        match f() {
688
            Ok(v) => return Ok(v),
689
            Err(e) => match tries.next() {
690
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
691
                _ => return Err(e),
692
            },
693
        }
694
    }
695
}
696
697
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
698
const FETCH_WAITS: [Duration; 5] =
699
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
700
e0918b88 Andy McCall 701
/// The runner's copy of a repository, fetched up to date with the job's token.
702
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
c23d5191 Andy McCall ↶ 703
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
704
}
705
706
/// The runner's copy of `repo`, cloned or fetched with `token`.
707
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
708
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
e0918b88 Andy McCall 709
    let set_remote = |repo: &mut Repo| -> Result<()> {
c23d5191 Andy McCall ↶ 710
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
711
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
e0918b88 Andy McCall 712
        if let Some(ca) = &cfg.ca {
713
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
714
        }
715
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
716
    };
717
    if dir.join(".oxy").is_dir() {
718
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
719
        set_remote(&mut repo)?;
f53e6d47 Andy McCall ↶ 720
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
e0918b88 Andy McCall 721
        return Ok(repo);
722
    }
723
    // First job for this repository: clone with the job's token.
c23d5191 Andy McCall ↶ 724
    std::env::set_var("OXY_TOKEN", token);
e0918b88 Andy McCall 725
    if let Some(ca) = &cfg.ca {
726
        std::env::set_var("OXY_CA", ca);
727
    }
f53e6d47 Andy McCall ↶ 728
    let cloned = with_retries(&FETCH_WAITS, || {
729
        let _ = std::fs::remove_dir_all(&dir);
730
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
731
    });
e0918b88 Andy McCall 732
    std::env::remove_var("OXY_TOKEN");
733
    std::env::remove_var("OXY_CA");
734
    let mut repo = cloned.map_err(|e| {
735
        let _ = std::fs::remove_dir_all(&dir);
c23d5191 Andy McCall ↶ 736
        anyhow::anyhow!("clone {}: {e}", clone_url)
e0918b88 Andy McCall 737
    })?;
738
    set_remote(&mut repo)?;
739
    Ok(repo)
bb643c25 Andy McCall ↶ 740
}
741
742
#[cfg(test)]
743
mod tests {
744
    use super::*;
f53e6d47 Andy McCall ↶ 745
746
    #[test]
747
    fn transient_network_errors_are_tried_again_and_others_are_not() {
748
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
749
        assert!(transient("HTTP 503: the hub is restarting"));
750
        assert!(!transient("HTTP 404: no such repository"));
751
        let mut n = 0;
752
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
753
            n += 1;
754
            if n < 3 {
755
                Err(anyhow::anyhow!("connection refused"))
756
            } else {
757
                Ok(7)
758
            }
759
        });
760
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
761
        let mut m = 0;
762
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
763
            m += 1;
764
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
765
        });
766
        assert!(got.is_err());
767
        assert_eq!(m, 1, "a refusal is not tried again");
768
        let mut k = 0;
769
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
770
            k += 1;
771
            Err(anyhow::anyhow!("timed out"))
772
        });
773
        assert!(got.is_err());
774
        assert_eq!(k, 3, "gives up after the last wait");
775
    }
1b2229da Andy McCall ↶ 776
777
    /// A hub that answers each request with the next of `codes`, counting them.
778
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
779
        use std::io::{Read, Write};
780
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
781
        let addr = listener.local_addr().unwrap();
782
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
783
        let count = seen.clone();
784
        std::thread::spawn(move || {
785
            for code in codes {
786
                let (mut s, _) = listener.accept().unwrap();
787
                let mut buf = [0u8; 8192];
788
                let _ = s.read(&mut buf);
789
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
790
                let reason = if code == 204 { "No Content" } else { "Error" };
791
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
792
            }
793
        });
794
        (format!("http://{addr}"), seen)
f98a8f78 Andy McCall ↶ 795
    }
796
797
    /// A hub that answers one request with `body` (status 200) and hands
798
    /// back what it was sent.
799
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
800
        use std::io::{Read, Write};
801
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
802
        let addr = listener.local_addr().unwrap();
803
        let (tx, rx) = std::sync::mpsc::channel();
804
        std::thread::spawn(move || {
805
            let (mut s, _) = listener.accept().unwrap();
806
            let mut req = Vec::new();
807
            let mut buf = [0u8; 65536];
808
            loop {
809
                let n = s.read(&mut buf).unwrap_or(0);
810
                if n == 0 {
811
                    break;
812
                }
813
                req.extend_from_slice(&buf[..n]);
814
                let text = String::from_utf8_lossy(&req);
815
                if let Some(end) = text.find("\r\n\r\n") {
816
                    let len = text[..end]
817
                        .lines()
818
                        .find_map(|l| {
819
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
820
                        })
821
                        .unwrap_or(0);
822
                    if req.len() >= end + 4 + len {
823
                        break;
824
                    }
825
                }
826
            }
827
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
828
            let _ = write!(
829
                s,
830
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
831
                body.len()
832
            );
833
        });
834
        (format!("http://{addr}"), rx)
835
    }
836
837
    #[test]
838
    fn registering_binds_the_key_and_keeps_the_credential_private() {
839
        use std::os::unix::fs::PermissionsExt;
840
        let dir = tempfile::tempdir().unwrap();
841
        let home = Home::locate(Some(dir.path())).unwrap();
842
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
843
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
844
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
845
        let req = sent.recv().unwrap();
846
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
847
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
848
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
849
        let key = home.runner_key("plex").unwrap();
850
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
851
        let path = dir.path().join(crate::home::RUNNER_TOML);
852
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
853
        // The runner then starts from runner.toml.
854
        let again = Home::locate(Some(dir.path())).unwrap();
855
        let cfg = again.config.runner.unwrap();
856
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
857
        assert_eq!(cfg.labels, ["gpu"]);
1b2229da Andy McCall ↶ 858
    }
859
860
    fn hub_at(base: String) -> Hub {
861
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
3007d8f0 Andy McCall ↶ 862
    }
863
864
    /// oxygenhub#71: a job's result is offered until the hub takes it,
865
    /// through server errors, and a refusal ends the trying at once.
866
    #[test]
867
    fn a_result_is_reported_until_the_hub_takes_it() {
868
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
869
        let quick = Duration::from_millis(10);
870
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
871
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
872
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
873
        let (base, seen) = fake_hub(vec![409]);
874
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
875
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1b2229da Andy McCall ↶ 876
    }
877
878
    #[test]
879
    fn server_errors_are_tried_again_and_client_errors_are_not() {
880
        let quick = [Duration::from_millis(10); 3];
881
        let (base, seen) = fake_hub(vec![500, 503, 204]);
882
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
883
        assert!(r.is_none());
884
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
885
886
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
887
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
888
        assert!(e.contains("HTTP 500"), "{e}");
889
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
890
891
        let (base, seen) = fake_hub(vec![401]);
892
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
893
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
280ef951 Andy McCall ↶ 894
    }
895
896
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
897
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
898
    #[test]
899
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
900
        use microvm::Sink;
901
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
902
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
903
        let (base, seen) = fake_hub(vec![204]);
904
        let hub = hub_at(base);
905
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
906
        assert!(!sink.cancelled());
907
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
908
        assert_eq!(n(&seen), 1);
909
910
        let (base, seen) = fake_hub(vec![409]);
911
        let hub = hub_at(base);
912
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
913
        assert!(sink.cancelled());
914
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
915
        sink.flush();
916
        assert!(sink.cancelled());
917
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1b2229da Andy McCall ↶ 918
    }
bb643c25 Andy McCall ↶ 919
920
    #[test]
921
    fn the_budget_admits_jobs_that_fit() {
922
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
923
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
924
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
925
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
926
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
927
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
928
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
929
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
930
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
931
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
932
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
933
    }
e0918b88 Andy McCall 934
}