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