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