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