OXYGENHUB
# 62829d15ef09 oxygenflow / crates / flow / src / runner.rs
47.1 KiB · Rust · last changed Runners send their facts with every claim: free disk, load, speed 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
        // The speed (spec §11.1): measured now, and again every 15 minutes
443
        // while no job runs, so a job never shares the CPU with it.
444
        let measure = || {
445
            let (speed, ms) = crate::facts::bench();
446
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
447
            speed
448
        };
449
        let mut speed = measure();
450
        let mut benched = Instant::now();
451
        loop {
452
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
453
                speed = measure();
454
                benched = Instant::now();
455
            }
456
            let room = budget.room(*used.lock().unwrap());
457
            let Some((free_memory_mib, free_cpus)) = room else {
458
                std::thread::sleep(Duration::from_millis(500));
459
                continue;
460
            };
461
            let req = ClaimRequest {
462
                runner: name.clone(),
463
                runtimes: runtimes.clone(),
464
                free_memory_mib,
465
                free_cpus,
466
                info: Some(RunnerInfo {
467
                    version: env!("OXY_VERSION_LONG").into(),
468
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
469
                }),
470
                // Read each time: an image imported while the runner runs counts.
471
                images: Some(home.images()),
472
                image_archs: Some(home.image_archs()),
473
                holding: Some(held.lock().unwrap().iter().copied().collect()),
474
                fetches_images: true,
475
                facts: Some(crate::facts::now(home, Some(speed))),
476
            };
477
            match hub.claim(&req) {
478
                Ok(Some(claim)) => {
479
                    idle = Duration::from_secs(2);
480
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
481
                    {
482
                        let mut u = used.lock().unwrap();
483
                        u.jobs += 1;
484
                        u.memory_mib += mem;
485
                        u.cpus += cpus;
486
                    }
487
                    held.lock().unwrap().insert(claim.job_id);
488
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
489
                    scope.spawn(move || {
490
                        let id = claim.job_id;
491
                        fetch_image(home, hub, cfg, &claim);
492
                        if let Some(why) = preflight(home, &claim) {
493
                            eprintln!("job {id}: declined: {why}");
494
                            if let Err(e) = hub.decline(id, &why) {
495
                                eprintln!("job {id}: decline: {e:#}");
496
                            }
497
                            held.lock().unwrap().remove(&id);
498
                            let mut u = used.lock().unwrap();
499
                            u.jobs -= 1;
500
                            u.memory_mib -= mem;
501
                            u.cpus -= cpus;
502
                            return;
503
                        }
504
                        eprintln!(
505
                            "job {id}: {} {} on {}",
506
                            claim.repo,
507
                            claim.job.name,
508
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
509
                        );
510
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
511
                            exit_code: None,
512
                            duration_ms: 0,
513
                            receipt: None,
514
                            signed_receipt: None,
515
                            error: Some(format!("{e:#}")),
516
                        });
517
                        eprintln!(
518
                            "job {id}: exit {:?}{}",
519
                            finish.exit_code,
520
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
521
                        );
522
                        if finish.error.as_deref() != Some(CANCELLED) {
523
                            report_finish(home, hub, id, &finish);
524
                        }
525
                        held.lock().unwrap().remove(&id);
526
                        let mut u = used.lock().unwrap();
527
                        u.jobs -= 1;
528
                        u.memory_mib -= mem;
529
                        u.cpus -= cpus;
530
                    });
531
                }
532
                Ok(None) => std::thread::sleep(idle),
533
                Err(e) => {
534
                    eprintln!("claim: {e:#}");
535
                    std::thread::sleep(idle);
536
                    idle = (idle * 2).min(Duration::from_secs(60));
537
                }
538
            }
539
        }
540
    })
541
}
542
543
/// Batches records to the hub every second (or every 200 lines). A quiet
544
/// job's sink still asks the hub every few seconds whether the job runs,
545
/// with an empty batch; once the hub answers 409 the job is not wanted.
546
struct LogSink<'a> {
547
    hub: &'a Hub,
548
    job: i64,
549
    buf: Vec<Record>,
550
    last: Instant,
551
    gone: bool,
552
}
553
554
/// How often a quiet job asks whether it was cancelled.
555
const HEARTBEAT: Duration = Duration::from_secs(5);
556
557
impl microvm::Sink for LogSink<'_> {
558
    fn record(&mut self, r: Record) {
559
        self.push(r)
560
    }
561
562
    fn cancelled(&mut self) -> bool {
563
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
564
            self.send();
565
        }
566
        self.gone
567
    }
568
}
569
570
impl LogSink<'_> {
571
    fn push(&mut self, mut r: Record) {
572
        if self.gone {
573
            return;
574
        }
575
        if r.text.len() > MAX_RECORD_TEXT {
576
            let mut cut = MAX_RECORD_TEXT;
577
            while !r.text.is_char_boundary(cut) {
578
                cut -= 1;
579
            }
580
            r.text.truncate(cut);
581
        }
582
        self.buf.push(r);
583
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
584
            self.flush();
585
        }
586
    }
587
588
    fn flush(&mut self) {
589
        if !self.buf.is_empty() {
590
            self.send();
591
        }
592
        self.last = Instant::now();
593
    }
594
595
    /// Send what is buffered, even nothing.
596
    fn send(&mut self) {
597
        if self.gone {
598
            return;
599
        }
600
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
601
            Ok(()) => {}
602
            Err(e) if job_gone(&e) => self.gone = true,
603
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
604
        }
605
        self.last = Instant::now();
606
    }
607
}
608
609
/// Held while a job's workspace is prepared (see `run_claim`).
610
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
611
612
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
613
    let started = Instant::now();
614
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
615
    let note =
616
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
617
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
618
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
619
    // Preparing a workspace (the cached repositories, the export, the
620
    // image) is one job at a time: fetches into a cached repository must not
621
    // overlap, and the first clone passes its token through the process
622
    // environment. Only the microVMs run side by side.
623
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
624
    let repo = cached_repo(home, cfg, claim)?;
625
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
626
    let _ = std::fs::remove_dir_all(&scratch);
627
    std::fs::create_dir_all(scratch.join("tree"))?;
628
    let result = (|| -> Result<Finish> {
629
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
630
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
631
        // workspace and of the scope hash.
632
        for sub in &claim.subrepos {
633
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
634
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
635
            }
636
            note(
637
                &mut sink,
638
                format!(
639
                    "fetching subrepo {} at {} for {}",
640
                    sub.repo,
641
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
642
                    sub.path
643
                ),
644
            );
645
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
646
            let sub_heads: Vec<ChangeHash> =
647
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
648
            let dir = scratch.join("tree").join(&sub.path);
649
            std::fs::create_dir_all(&dir)?;
650
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
651
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
652
        }
653
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
654
        // The syscall tier runs on the tree itself; a microVM gets an image.
655
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
656
        if !syscall_tier {
657
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
658
            std::fs::remove_dir_all(scratch.join("tree"))?;
659
        }
660
        preparing.take();
661
        // Cancelled while it was being prepared: no sandbox.
662
        if microvm::Sink::cancelled(&mut sink) {
663
            return Ok(cancelled());
664
        }
665
        note(
666
            &mut sink,
667
            format!(
668
                "{}: {} {}, {} MiB, rootfs {}",
669
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
670
                claim.sandbox.cpus,
671
                if syscall_tier { "CPU" } else { "vCPU" },
672
                claim.sandbox.memory_mib,
673
                &claim.sandbox.rootfs[..12]
674
            ),
675
        );
676
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
677
        let timeout = Duration::from_secs(claim.timeout_s);
678
        let outcome = if syscall_tier {
679
            let group = format!("job-{}", claim.job_id);
680
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
681
        } else {
682
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
683
        };
684
        if outcome.cancelled {
685
            return Ok(cancelled());
686
        }
687
        // A passing job's artifacts, read from its disks and uploaded; a
688
        // declared artifact that cannot be kept fails the job.
689
        let mut outputs = vec![];
690
        let mut output_error = None;
691
        if outcome.exit_code == Some(0) {
692
            for o in &claim.job.outputs {
693
                let read = if syscall_tier {
694
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
695
                    crate::outputs::read_dir_file(&dir, &inner)
696
                } else {
697
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
698
                    crate::outputs::read(&image, &inner, &scratch)
699
                };
700
                let kept = read.and_then(|bytes| {
701
                    let blob = Repo::blob_ref(&bytes);
702
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
703
                    if named != blob.to_hex() {
704
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
705
                    }
706
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
707
                    Ok(blob.0)
708
                });
709
                match kept {
710
                    Ok(b) => outputs.push(b),
711
                    Err(e) => {
712
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
713
                        break;
714
                    }
715
                }
716
            }
717
        }
718
        if let Some(e) = &output_error {
719
            note(&mut sink, e.clone());
720
        }
721
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
722
            job_name: claim.job.name.clone(),
723
            pipeline_hash: hex32(&claim.pipeline_hash),
724
            scope_tree_hash: scope_hash,
725
            inputs: vec![],
726
            environment: outcome.environment.hash(),
727
            outputs,
728
            exit_code: code as u32,
729
            duration_ms: outcome.duration_ms,
730
        });
731
        let signed_receipt = match &receipt {
732
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
733
            None => None,
734
        };
735
        Ok(Finish {
736
            exit_code: outcome.exit_code,
737
            duration_ms: started.elapsed().as_millis() as u64,
738
            receipt,
739
            signed_receipt,
740
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
741
        })
742
    })();
743
    let _ = workspace::remove_tree(&scratch);
744
    sink.flush();
745
    result
746
}
747
748
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
749
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
750
/// the claim, so the runner vouches only for what it fetched.
751
fn sign_receipt(
752
    repo: &Repo,
753
    heads: &[ChangeHash],
754
    cfg: &RunnerConfig,
755
    key: &RunnerKey,
756
    claim: &Claim,
757
    receipt: &Receipt,
758
) -> Result<oxy_core::receipt::SignedReceipt> {
759
    let state = state_of(heads);
760
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
761
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
762
}
763
764
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
765
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
766
    use oxy_core::hash::{hash_object, ObjectType};
767
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
768
}
769
770
fn hex32(s: &str) -> [u8; 32] {
771
    let mut out = [0; 32];
772
    if let Ok(v) = hex::decode(s) {
773
        if v.len() == 32 {
774
            out.copy_from_slice(&v);
775
        }
776
    }
777
    out
778
}
779
780
/// Whether a network error is worth another try: DNS, a refused or reset
781
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
782
/// hub restarts (oxygenhub#111).
783
fn transient(msg: &str) -> bool {
784
    let m = msg.to_lowercase();
785
    [
786
        "dns failed",
787
        "name resolution",
788
        "connection refused",
789
        "connection reset",
790
        "timed out",
791
        "timeout",
792
        "http 502",
793
        "http 503",
794
        "http 504",
795
        "broken pipe",
796
        "unexpected eof",
797
    ]
798
    .iter()
799
    .any(|k| m.contains(k))
800
}
801
802
/// Run `f` again after a transient network error, waiting `waits` between
803
/// tries (about a minute in all), and give up on any other error at once.
804
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
805
    let mut tries = waits.iter();
806
    loop {
807
        match f() {
808
            Ok(v) => return Ok(v),
809
            Err(e) => match tries.next() {
810
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
811
                _ => return Err(e),
812
            },
813
        }
814
    }
815
}
816
817
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
818
const FETCH_WAITS: [Duration; 5] =
819
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
820
821
/// The runner's copy of a repository, fetched up to date with the job's token.
822
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
823
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
824
}
825
826
/// The runner's copy of `repo`, cloned or fetched with `token`.
827
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
828
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
829
    let set_remote = |repo: &mut Repo| -> Result<()> {
830
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
831
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
832
        if let Some(ca) = &cfg.ca {
833
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
834
        }
835
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
836
    };
837
    if dir.join(".oxy").is_dir() {
838
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
839
        set_remote(&mut repo)?;
840
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
841
        return Ok(repo);
842
    }
843
    // First job for this repository: clone with the job's token.
844
    std::env::set_var("OXY_TOKEN", token);
845
    if let Some(ca) = &cfg.ca {
846
        std::env::set_var("OXY_CA", ca);
847
    }
848
    let cloned = with_retries(&FETCH_WAITS, || {
849
        let _ = std::fs::remove_dir_all(&dir);
850
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
851
    });
852
    std::env::remove_var("OXY_TOKEN");
853
    std::env::remove_var("OXY_CA");
854
    let mut repo = cloned.map_err(|e| {
855
        let _ = std::fs::remove_dir_all(&dir);
856
        anyhow::anyhow!("clone {}: {e}", clone_url)
857
    })?;
858
    set_remote(&mut repo)?;
859
    Ok(repo)
860
}
861
862
#[cfg(test)]
863
mod tests {
864
    use super::*;
865
866
    #[test]
867
    fn a_runner_names_what_stops_a_sandbox() {
868
        let dir = tempfile::tempdir().unwrap();
869
        let home = Home::locate(Some(dir.path())).unwrap();
870
        let bytes = b"not really ext4";
871
        let hash = blake3::hash(bytes).to_hex().to_string();
872
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
873
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
874
        }))
875
        .unwrap();
876
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
877
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
878
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
879
        sb.rootfs = hash.clone();
880
        assert_eq!(problem(&sb, Some(4096)), None);
881
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
882
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
883
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
884
        sb.arch = Some("aarch64".into());
885
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
886
        sb.arch = None;
887
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
888
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
889
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
890
    }
891
892
    #[test]
893
    fn transient_network_errors_are_tried_again_and_others_are_not() {
894
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
895
        assert!(transient("HTTP 503: the hub is restarting"));
896
        assert!(!transient("HTTP 404: no such repository"));
897
        let mut n = 0;
898
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
899
            n += 1;
900
            if n < 3 {
901
                Err(anyhow::anyhow!("connection refused"))
902
            } else {
903
                Ok(7)
904
            }
905
        });
906
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
907
        let mut m = 0;
908
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
909
            m += 1;
910
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
911
        });
912
        assert!(got.is_err());
913
        assert_eq!(m, 1, "a refusal is not tried again");
914
        let mut k = 0;
915
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
916
            k += 1;
917
            Err(anyhow::anyhow!("timed out"))
918
        });
919
        assert!(got.is_err());
920
        assert_eq!(k, 3, "gives up after the last wait");
921
    }
922
923
    /// A hub that answers each request with the next of `codes`, counting them.
924
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
925
        use std::io::{Read, Write};
926
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
927
        let addr = listener.local_addr().unwrap();
928
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
929
        let count = seen.clone();
930
        std::thread::spawn(move || {
931
            for code in codes {
932
                let (mut s, _) = listener.accept().unwrap();
933
                let mut buf = [0u8; 8192];
934
                let _ = s.read(&mut buf);
935
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
936
                let reason = if code == 204 { "No Content" } else { "Error" };
937
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
938
            }
939
        });
940
        (format!("http://{addr}"), seen)
941
    }
942
943
    /// A hub that answers one request with `body` (status 200) and hands
944
    /// back what it was sent.
945
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
946
        use std::io::{Read, Write};
947
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
948
        let addr = listener.local_addr().unwrap();
949
        let (tx, rx) = std::sync::mpsc::channel();
950
        std::thread::spawn(move || {
951
            let (mut s, _) = listener.accept().unwrap();
952
            let mut req = Vec::new();
953
            let mut buf = [0u8; 65536];
954
            loop {
955
                let n = s.read(&mut buf).unwrap_or(0);
956
                if n == 0 {
957
                    break;
958
                }
959
                req.extend_from_slice(&buf[..n]);
960
                let text = String::from_utf8_lossy(&req);
961
                if let Some(end) = text.find("\r\n\r\n") {
962
                    let len = text[..end]
963
                        .lines()
964
                        .find_map(|l| {
965
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
966
                        })
967
                        .unwrap_or(0);
968
                    if req.len() >= end + 4 + len {
969
                        break;
970
                    }
971
                }
972
            }
973
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
974
            let _ = write!(
975
                s,
976
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
977
                body.len()
978
            );
979
        });
980
        (format!("http://{addr}"), rx)
981
    }
982
983
    #[test]
984
    fn registering_binds_the_key_and_keeps_the_credential_private() {
985
        use std::os::unix::fs::PermissionsExt;
986
        let dir = tempfile::tempdir().unwrap();
987
        let home = Home::locate(Some(dir.path())).unwrap();
988
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
989
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
990
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
991
        let req = sent.recv().unwrap();
992
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
993
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
994
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
995
        let key = home.runner_key("plex").unwrap();
996
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
997
        let path = dir.path().join(crate::home::RUNNER_TOML);
998
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
999
        // The runner then starts from runner.toml.
1000
        let again = Home::locate(Some(dir.path())).unwrap();
1001
        let cfg = again.config.runner.unwrap();
1002
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
1003
        assert_eq!(cfg.labels, ["gpu"]);
1004
    }
1005
1006
    fn hub_at(base: String) -> Hub {
1007
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
1008
    }
1009
1010
    /// oxygenhub#71: a job's result is offered until the hub takes it,
1011
    /// through server errors, and a refusal ends the trying at once.
1012
    #[test]
1013
    fn a_result_is_reported_until_the_hub_takes_it() {
1014
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
1015
        let quick = Duration::from_millis(10);
1016
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
1017
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
1018
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
1019
        let (base, seen) = fake_hub(vec![409]);
1020
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
1021
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1022
    }
1023
1024
    #[test]
1025
    fn server_errors_are_tried_again_and_client_errors_are_not() {
1026
        let quick = [Duration::from_millis(10); 3];
1027
        let (base, seen) = fake_hub(vec![500, 503, 204]);
1028
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
1029
        assert!(r.is_none());
1030
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
1031
1032
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
1033
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
1034
        assert!(e.contains("HTTP 500"), "{e}");
1035
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
1036
1037
        let (base, seen) = fake_hub(vec![401]);
1038
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
1039
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1040
    }
1041
1042
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
1043
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
1044
    #[test]
1045
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
1046
        use microvm::Sink;
1047
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1048
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
1049
        let (base, seen) = fake_hub(vec![204]);
1050
        let hub = hub_at(base);
1051
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1052
        assert!(!sink.cancelled());
1053
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
1054
        assert_eq!(n(&seen), 1);
1055
1056
        let (base, seen) = fake_hub(vec![409]);
1057
        let hub = hub_at(base);
1058
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1059
        assert!(sink.cancelled());
1060
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
1061
        sink.flush();
1062
        assert!(sink.cancelled());
1063
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1064
    }
1065
1066
    #[test]
1067
    fn the_budget_admits_jobs_that_fit() {
1068
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1069
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1070
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1071
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1072
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1073
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1074
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1075
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1076
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1077
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1078
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1079
    }
1080
}