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