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