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