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