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