OXYGENHUB
# ea56c56eaf90 oxygenflow / crates / flow / src / runner.rs
39.8 KiB · Rust · last changed The syscall tier: jobs without KVM 1 month ago source blame history raw
1
//! `flow runner` (docs/m0.md §5): claim a job from OxygenHub, fetch the
2
//! repository into a cache with the job's token, write the state into a
3
//! workspace, run it in its tier, stream the log, report the receipt.
4
//! One job at a time.
5
6
use crate::home::{Home, RunnerConfig, RunnerKey};
7
use crate::{microvm, workspace};
8
use anyhow::{bail, Context, Result};
9
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT};
10
use flow_core::{scope_tree_hash, Receipt};
11
use oxy_core::hash::ChangeHash;
12
use oxy_core::repo::Repo;
13
use std::path::PathBuf;
14
use std::time::{Duration, Instant};
15
16
/// A request the hub refused (4xx). On a job's endpoints, 409 means the
17
/// job no longer runs: it was cancelled, or given up.
18
#[derive(Debug)]
19
pub struct Refused {
20
    pub code: u16,
21
    pub text: String,
22
}
23
24
impl std::fmt::Display for Refused {
25
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26
        write!(f, "HTTP {}: {}", self.code, self.text)
27
    }
28
}
29
30
impl std::error::Error for Refused {}
31
32
/// Whether `e` is the hub saying the job no longer runs.
33
fn job_gone(e: &anyhow::Error) -> bool {
34
    e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409)
35
}
36
37
/// What a job ends with when its run was cancelled: nothing is reported,
38
/// as the hub has ended it already.
39
const CANCELLED: &str = "the run was cancelled";
40
41
fn cancelled() -> Finish {
42
    Finish { exit_code: None, duration_ms: 0, receipt: None, signed_receipt: None, error: Some(CANCELLED.into()) }
43
}
44
45
pub struct Hub {
46
    base: String,
47
    token: String,
48
    agent: ureq::Agent,
49
}
50
51
impl Hub {
52
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
53
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
54
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
55
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
56
    }
57
58
    /// POST to the hub. A server error (5xx) means nothing was committed,
59
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
60
    /// busy hub costs a job a few seconds, not its log lines or its claim.
61
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
62
        self.post_with(path, body, &RETRY_DELAYS)
63
    }
64
65
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
66
        let url = format!("{}/api/v1{path}", self.base);
67
        let mut delays = delays.iter();
68
        loop {
69
            match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
70
                Ok(r) if r.status() == 204 => return Ok(None),
71
                Ok(r) => return Ok(Some(r)),
72
                Err(ureq::Error::Status(code, r)) => {
73
                    let text = r.into_string().unwrap_or_default();
74
                    match delays.next() {
75
                        Some(d) if code >= 500 => std::thread::sleep(*d),
76
                        _ if code < 500 => return Err(anyhow::Error::new(Refused { code, text }).context(url)),
77
                        _ => bail!("{url}: HTTP {code}: {text}"),
78
                    }
79
                }
80
                Err(e) => bail!("{url}: {e}"),
81
            }
82
        }
83
    }
84
85
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
86
        match self.post("/flow/claim", req)? {
87
            Some(r) => Ok(Some(r.into_json()?)),
88
            None => Ok(None),
89
        }
90
    }
91
92
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
93
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
94
        Ok(())
95
    }
96
97
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
98
    /// a blob of the job's repository and answers with its ObjectRef.
99
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
100
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
101
        let mut delays = RETRY_DELAYS.iter();
102
        loop {
103
            let req = self
104
                .agent
105
                .post(&url)
106
                .timeout(Duration::from_secs(600))
107
                .set("Authorization", &format!("Bearer {}", self.token))
108
                .set("Content-Type", "application/octet-stream");
109
            match req.send_bytes(bytes) {
110
                Ok(r) => {
111
                    let v: serde_json::Value = r.into_json()?;
112
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
113
                }
114
                Err(ureq::Error::Status(code, r)) => {
115
                    let text = r.into_string().unwrap_or_default();
116
                    match delays.next() {
117
                        Some(d) if code >= 500 => std::thread::sleep(*d),
118
                        _ => bail!("{url}: HTTP {code}: {text}"),
119
                    }
120
                }
121
                Err(e) => bail!("{url}: {e}"),
122
            }
123
        }
124
    }
125
126
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
127
    /// cannot be reached or fails (5xx), try again after `first_wait`,
128
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
129
    /// already, say) is final. A result lost here leaves the job running
130
    /// on the hub until its time limit.
131
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
132
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
133
        let deadline = std::time::Instant::now() + patience;
134
        let mut wait = first_wait;
135
        loop {
136
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
137
                Ok(_) => return Ok(()),
138
                Err(ureq::Error::Status(code, r)) if code < 500 => {
139
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
140
                }
141
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
142
                Err(e) => e.to_string(),
143
            };
144
            if std::time::Instant::now() + wait > deadline {
145
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
146
            }
147
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
148
            std::thread::sleep(wait);
149
            wait = (wait * 2).min(Duration::from_secs(60));
150
        }
151
    }
152
}
153
154
/// How long a runner keeps trying to report a job's end.
155
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
156
157
/// Where a job's result waits until the hub has it, so a runner that
158
/// restarts still reports it.
159
fn pending_path(home: &Home, job: i64) -> PathBuf {
160
    home.root.join("pending").join(format!("{job}.json"))
161
}
162
163
/// Report `f`, keeping it on disk until the hub has it.
164
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
165
    let path = pending_path(home, job);
166
    let kept = std::fs::create_dir_all(path.parent().unwrap())
167
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
168
        .is_ok();
169
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
170
        Ok(()) => {}
171
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
172
    }
173
    if kept {
174
        let _ = std::fs::remove_file(&path);
175
    }
176
}
177
178
/// Results a previous run of the runner could not report, to send again.
179
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
180
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
181
        return Vec::new();
182
    };
183
    dir.filter_map(|e| {
184
        let p = e.ok()?.path();
185
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
186
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
187
        Some((job, f))
188
    })
189
    .collect()
190
}
191
192
/// `flow runner register`: exchange a one-time registration token from
193
/// the hub for this runner's credential, binding the runner's signing key,
194
/// and keep hub, credential and name in runner.toml.
195
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
196
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
197
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
198
    }
199
    let key = home.runner_key(name)?;
200
    let hub = hub.trim_end_matches('/').to_string();
201
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
202
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
203
    let req = Register {
204
        token: token.trim().to_string(),
205
        name: name.to_string(),
206
        identity: hex::encode(key.identity.encode()),
207
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
208
    };
209
    let url = format!("{hub}/api/v1/flow/runners/register");
210
    let got: Registered = match agent.post(&url).send_json(&req) {
211
        Ok(r) => r.into_json()?,
212
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
213
        Err(e) => bail!("{url}: {e}"),
214
    };
215
    let mut text = format!(
216
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
217
        got.credential, got.name
218
    );
219
    if let Some(ca) = &ca {
220
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
221
    }
222
    if !labels.is_empty() {
223
        text.push_str(&format!("labels = {labels:?}\n"));
224
    }
225
    let path = home.root.join(crate::home::RUNNER_TOML);
226
    {
227
        use std::io::Write;
228
        use std::os::unix::fs::OpenOptionsExt;
229
        let tmp = path.with_extension("toml.tmp");
230
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
231
        f.write_all(text.as_bytes())?;
232
        f.sync_all()?;
233
        std::fs::rename(&tmp, &path)?;
234
    }
235
    Ok(got)
236
}
237
238
/// Waits before trying a hub request again after a server error.
239
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
240
241
/// What the runner may run at once (flow.toml `[runner]`): at most
242
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
243
#[derive(Debug, Default, PartialEq, Eq)]
244
pub struct Budget {
245
    pub max_jobs: usize,
246
    pub memory_mib: Option<u32>,
247
    pub cpus: Option<u32>,
248
}
249
250
/// What is in use now.
251
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
252
pub struct Used {
253
    pub jobs: usize,
254
    pub memory_mib: u32,
255
    pub cpus: u32,
256
}
257
258
impl Budget {
259
    pub fn of(cfg: &RunnerConfig) -> Budget {
260
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
261
    }
262
263
    /// The room to ask the hub for, or `None` when no job more can start.
264
    /// A lone job may use the whole budget even if it asks for more, so a
265
    /// large sandbox is never starved.
266
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
267
        if used.jobs >= self.max_jobs {
268
            return None;
269
        }
270
        if used.jobs == 0 {
271
            return Some((None, None));
272
        }
273
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
274
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
275
        if mem == Some(0) || cpus == Some(0) {
276
            return None;
277
        }
278
        Some((mem, cpus))
279
    }
280
}
281
282
/// The isolation tiers this machine can honour now (spec §11.1): the
283
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
284
/// flow.toml pins Firecracker; the syscall tier when this binary may use
285
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
286
/// `Err` says why there are none; the reasons for missing tiers come back
287
/// with the ones there are.
288
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
289
    let mut have = Vec::new();
290
    let mut missing = Vec::new();
291
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
292
    match (kvm, home.config.microvm.is_some()) {
293
        (Ok(_), true) => have.push("microvm".to_string()),
294
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
295
        (Err(e), _) => missing.push(format!(
296
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
297
        )),
298
    }
299
    match crate::syscall::available() {
300
        Ok(()) => have.push("syscall".to_string()),
301
        Err(e) => missing.push(format!("syscall: {e}")),
302
    }
303
    if have.is_empty() {
304
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
305
    }
306
    Ok((have, missing))
307
}
308
309
pub fn serve(home: &Home) -> Result<()> {
310
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
311
    // A runner offers only what it can run: a machine without KVM claims
312
    // nothing rather than taking MicroVM jobs and failing them (#36).
313
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
314
    for why in &missing {
315
        eprintln!("flow runner: not offering {why}");
316
    }
317
    let hub = Hub::new(&cfg)?;
318
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
319
    let budget = Budget::of(&cfg);
320
    let key = home.runner_key(&name)?;
321
    eprintln!(
322
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
323
        cfg.hub,
324
        budget.max_jobs,
325
        runtimes.join(", "),
326
        key.identity.hash().to_hex()
327
    );
328
    let used = std::sync::Mutex::new(Used::default());
329
    let mut idle = Duration::from_secs(2);
330
    let pending = report_pending(home);
331
    // The jobs held now, told to the hub at each claim so it can end the
332
    // ones this runner lost: a job is held from its claim until its result
333
    // is reported.
334
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
335
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
336
    std::thread::scope(|scope| {
337
        for (job, f) in pending {
338
            let (hub, held) = (&hub, &held);
339
            scope.spawn(move || {
340
                eprintln!("job {job}: reporting the result kept from before the restart");
341
                report_finish(home, hub, job, &f);
342
                held.lock().unwrap().remove(&job);
343
            });
344
        }
345
        loop {
346
            let room = budget.room(*used.lock().unwrap());
347
            let Some((free_memory_mib, free_cpus)) = room else {
348
                std::thread::sleep(Duration::from_millis(500));
349
                continue;
350
            };
351
            let req = ClaimRequest {
352
                runner: name.clone(),
353
                runtimes: runtimes.clone(),
354
                free_memory_mib,
355
                free_cpus,
356
                info: Some(RunnerInfo {
357
                    version: env!("OXY_VERSION_LONG").into(),
358
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
359
                }),
360
                // Read each time: an image imported while the runner runs counts.
361
                images: Some(home.images()),
362
                image_archs: Some(home.image_archs()),
363
                holding: Some(held.lock().unwrap().iter().copied().collect()),
364
            };
365
            match hub.claim(&req) {
366
                Ok(Some(claim)) => {
367
                    idle = Duration::from_secs(2);
368
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
369
                    {
370
                        let mut u = used.lock().unwrap();
371
                        u.jobs += 1;
372
                        u.memory_mib += mem;
373
                        u.cpus += cpus;
374
                    }
375
                    held.lock().unwrap().insert(claim.job_id);
376
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
377
                    scope.spawn(move || {
378
                        let id = claim.job_id;
379
                        eprintln!(
380
                            "job {id}: {} {} on {}",
381
                            claim.repo,
382
                            claim.job.name,
383
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
384
                        );
385
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
386
                            exit_code: None,
387
                            duration_ms: 0,
388
                            receipt: None,
389
                            signed_receipt: None,
390
                            error: Some(format!("{e:#}")),
391
                        });
392
                        eprintln!(
393
                            "job {id}: exit {:?}{}",
394
                            finish.exit_code,
395
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
396
                        );
397
                        if finish.error.as_deref() != Some(CANCELLED) {
398
                            report_finish(home, hub, id, &finish);
399
                        }
400
                        held.lock().unwrap().remove(&id);
401
                        let mut u = used.lock().unwrap();
402
                        u.jobs -= 1;
403
                        u.memory_mib -= mem;
404
                        u.cpus -= cpus;
405
                    });
406
                }
407
                Ok(None) => std::thread::sleep(idle),
408
                Err(e) => {
409
                    eprintln!("claim: {e:#}");
410
                    std::thread::sleep(idle);
411
                    idle = (idle * 2).min(Duration::from_secs(60));
412
                }
413
            }
414
        }
415
    })
416
}
417
418
/// Batches records to the hub every second (or every 200 lines). A quiet
419
/// job's sink still asks the hub every few seconds whether the job runs,
420
/// with an empty batch; once the hub answers 409 the job is not wanted.
421
struct LogSink<'a> {
422
    hub: &'a Hub,
423
    job: i64,
424
    buf: Vec<Record>,
425
    last: Instant,
426
    gone: bool,
427
}
428
429
/// How often a quiet job asks whether it was cancelled.
430
const HEARTBEAT: Duration = Duration::from_secs(5);
431
432
impl microvm::Sink for LogSink<'_> {
433
    fn record(&mut self, r: Record) {
434
        self.push(r)
435
    }
436
437
    fn cancelled(&mut self) -> bool {
438
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
439
            self.send();
440
        }
441
        self.gone
442
    }
443
}
444
445
impl LogSink<'_> {
446
    fn push(&mut self, mut r: Record) {
447
        if self.gone {
448
            return;
449
        }
450
        if r.text.len() > MAX_RECORD_TEXT {
451
            let mut cut = MAX_RECORD_TEXT;
452
            while !r.text.is_char_boundary(cut) {
453
                cut -= 1;
454
            }
455
            r.text.truncate(cut);
456
        }
457
        self.buf.push(r);
458
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
459
            self.flush();
460
        }
461
    }
462
463
    fn flush(&mut self) {
464
        if !self.buf.is_empty() {
465
            self.send();
466
        }
467
        self.last = Instant::now();
468
    }
469
470
    /// Send what is buffered, even nothing.
471
    fn send(&mut self) {
472
        if self.gone {
473
            return;
474
        }
475
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
476
            Ok(()) => {}
477
            Err(e) if job_gone(&e) => self.gone = true,
478
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
479
        }
480
        self.last = Instant::now();
481
    }
482
}
483
484
/// Held while a job's workspace is prepared (see `run_claim`).
485
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
486
487
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
488
    let started = Instant::now();
489
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
490
    let note =
491
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
492
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
493
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
494
    // Preparing a workspace (the cached repositories, the export, the
495
    // image) is one job at a time: fetches into a cached repository must not
496
    // overlap, and the first clone passes its token through the process
497
    // environment. Only the microVMs run side by side.
498
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
499
    let repo = cached_repo(home, cfg, claim)?;
500
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
501
    let _ = std::fs::remove_dir_all(&scratch);
502
    std::fs::create_dir_all(scratch.join("tree"))?;
503
    let result = (|| -> Result<Finish> {
504
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
505
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
506
        // workspace and of the scope hash.
507
        for sub in &claim.subrepos {
508
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
509
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
510
            }
511
            note(
512
                &mut sink,
513
                format!(
514
                    "fetching subrepo {} at {} for {}",
515
                    sub.repo,
516
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
517
                    sub.path
518
                ),
519
            );
520
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
521
            let sub_heads: Vec<ChangeHash> =
522
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
523
            let dir = scratch.join("tree").join(&sub.path);
524
            std::fs::create_dir_all(&dir)?;
525
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
526
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
527
        }
528
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
529
        // The syscall tier runs on the tree itself; a microVM gets an image.
530
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
531
        if !syscall_tier {
532
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
533
            std::fs::remove_dir_all(scratch.join("tree"))?;
534
        }
535
        preparing.take();
536
        // Cancelled while it was being prepared: no sandbox.
537
        if microvm::Sink::cancelled(&mut sink) {
538
            return Ok(cancelled());
539
        }
540
        note(
541
            &mut sink,
542
            format!(
543
                "{}: {} {}, {} MiB, rootfs {}",
544
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
545
                claim.sandbox.cpus,
546
                if syscall_tier { "CPU" } else { "vCPU" },
547
                claim.sandbox.memory_mib,
548
                &claim.sandbox.rootfs[..12]
549
            ),
550
        );
551
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
552
        let timeout = Duration::from_secs(claim.timeout_s);
553
        let outcome = if syscall_tier {
554
            let group = format!("job-{}", claim.job_id);
555
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
556
        } else {
557
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
558
        };
559
        if outcome.cancelled {
560
            return Ok(cancelled());
561
        }
562
        // A passing job's artifacts, read from its disks and uploaded; a
563
        // declared artifact that cannot be kept fails the job.
564
        let mut outputs = vec![];
565
        let mut output_error = None;
566
        if outcome.exit_code == Some(0) {
567
            for o in &claim.job.outputs {
568
                let read = if syscall_tier {
569
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
570
                    crate::outputs::read_dir_file(&dir, &inner)
571
                } else {
572
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
573
                    crate::outputs::read(&image, &inner, &scratch)
574
                };
575
                let kept = read.and_then(|bytes| {
576
                    let blob = Repo::blob_ref(&bytes);
577
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
578
                    if named != blob.to_hex() {
579
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
580
                    }
581
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
582
                    Ok(blob.0)
583
                });
584
                match kept {
585
                    Ok(b) => outputs.push(b),
586
                    Err(e) => {
587
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
588
                        break;
589
                    }
590
                }
591
            }
592
        }
593
        if let Some(e) = &output_error {
594
            note(&mut sink, e.clone());
595
        }
596
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
597
            job_name: claim.job.name.clone(),
598
            pipeline_hash: hex32(&claim.pipeline_hash),
599
            scope_tree_hash: scope_hash,
600
            inputs: vec![],
601
            environment: outcome.environment.hash(),
602
            outputs,
603
            exit_code: code as u32,
604
            duration_ms: outcome.duration_ms,
605
        });
606
        let signed_receipt = match &receipt {
607
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
608
            None => None,
609
        };
610
        Ok(Finish {
611
            exit_code: outcome.exit_code,
612
            duration_ms: started.elapsed().as_millis() as u64,
613
            receipt,
614
            signed_receipt,
615
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
616
        })
617
    })();
618
    let _ = workspace::remove_tree(&scratch);
619
    sink.flush();
620
    result
621
}
622
623
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
624
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
625
/// the claim, so the runner vouches only for what it fetched.
626
fn sign_receipt(
627
    repo: &Repo,
628
    heads: &[ChangeHash],
629
    cfg: &RunnerConfig,
630
    key: &RunnerKey,
631
    claim: &Claim,
632
    receipt: &Receipt,
633
) -> Result<oxy_core::receipt::SignedReceipt> {
634
    let state = state_of(heads);
635
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
636
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
637
}
638
639
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
640
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
641
    use oxy_core::hash::{hash_object, ObjectType};
642
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
643
}
644
645
fn hex32(s: &str) -> [u8; 32] {
646
    let mut out = [0; 32];
647
    if let Ok(v) = hex::decode(s) {
648
        if v.len() == 32 {
649
            out.copy_from_slice(&v);
650
        }
651
    }
652
    out
653
}
654
655
/// Whether a network error is worth another try: DNS, a refused or reset
656
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
657
/// hub restarts (oxygenhub#111).
658
fn transient(msg: &str) -> bool {
659
    let m = msg.to_lowercase();
660
    [
661
        "dns failed",
662
        "name resolution",
663
        "connection refused",
664
        "connection reset",
665
        "timed out",
666
        "timeout",
667
        "http 502",
668
        "http 503",
669
        "http 504",
670
        "broken pipe",
671
        "unexpected eof",
672
    ]
673
    .iter()
674
    .any(|k| m.contains(k))
675
}
676
677
/// Run `f` again after a transient network error, waiting `waits` between
678
/// tries (about a minute in all), and give up on any other error at once.
679
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
680
    let mut tries = waits.iter();
681
    loop {
682
        match f() {
683
            Ok(v) => return Ok(v),
684
            Err(e) => match tries.next() {
685
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
686
                _ => return Err(e),
687
            },
688
        }
689
    }
690
}
691
692
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
693
const FETCH_WAITS: [Duration; 5] =
694
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
695
696
/// The runner's copy of a repository, fetched up to date with the job's token.
697
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
698
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
699
}
700
701
/// The runner's copy of `repo`, cloned or fetched with `token`.
702
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
703
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
704
    let set_remote = |repo: &mut Repo| -> Result<()> {
705
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
706
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
707
        if let Some(ca) = &cfg.ca {
708
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
709
        }
710
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
711
    };
712
    if dir.join(".oxy").is_dir() {
713
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
714
        set_remote(&mut repo)?;
715
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
716
        return Ok(repo);
717
    }
718
    // First job for this repository: clone with the job's token.
719
    std::env::set_var("OXY_TOKEN", token);
720
    if let Some(ca) = &cfg.ca {
721
        std::env::set_var("OXY_CA", ca);
722
    }
723
    let cloned = with_retries(&FETCH_WAITS, || {
724
        let _ = std::fs::remove_dir_all(&dir);
725
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
726
    });
727
    std::env::remove_var("OXY_TOKEN");
728
    std::env::remove_var("OXY_CA");
729
    let mut repo = cloned.map_err(|e| {
730
        let _ = std::fs::remove_dir_all(&dir);
731
        anyhow::anyhow!("clone {}: {e}", clone_url)
732
    })?;
733
    set_remote(&mut repo)?;
734
    Ok(repo)
735
}
736
737
#[cfg(test)]
738
mod tests {
739
    use super::*;
740
741
    #[test]
742
    fn transient_network_errors_are_tried_again_and_others_are_not() {
743
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
744
        assert!(transient("HTTP 503: the hub is restarting"));
745
        assert!(!transient("HTTP 404: no such repository"));
746
        let mut n = 0;
747
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
748
            n += 1;
749
            if n < 3 {
750
                Err(anyhow::anyhow!("connection refused"))
751
            } else {
752
                Ok(7)
753
            }
754
        });
755
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
756
        let mut m = 0;
757
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
758
            m += 1;
759
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
760
        });
761
        assert!(got.is_err());
762
        assert_eq!(m, 1, "a refusal is not tried again");
763
        let mut k = 0;
764
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
765
            k += 1;
766
            Err(anyhow::anyhow!("timed out"))
767
        });
768
        assert!(got.is_err());
769
        assert_eq!(k, 3, "gives up after the last wait");
770
    }
771
772
    /// A hub that answers each request with the next of `codes`, counting them.
773
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
774
        use std::io::{Read, Write};
775
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
776
        let addr = listener.local_addr().unwrap();
777
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
778
        let count = seen.clone();
779
        std::thread::spawn(move || {
780
            for code in codes {
781
                let (mut s, _) = listener.accept().unwrap();
782
                let mut buf = [0u8; 8192];
783
                let _ = s.read(&mut buf);
784
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
785
                let reason = if code == 204 { "No Content" } else { "Error" };
786
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
787
            }
788
        });
789
        (format!("http://{addr}"), seen)
790
    }
791
792
    /// A hub that answers one request with `body` (status 200) and hands
793
    /// back what it was sent.
794
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
795
        use std::io::{Read, Write};
796
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
797
        let addr = listener.local_addr().unwrap();
798
        let (tx, rx) = std::sync::mpsc::channel();
799
        std::thread::spawn(move || {
800
            let (mut s, _) = listener.accept().unwrap();
801
            let mut req = Vec::new();
802
            let mut buf = [0u8; 65536];
803
            loop {
804
                let n = s.read(&mut buf).unwrap_or(0);
805
                if n == 0 {
806
                    break;
807
                }
808
                req.extend_from_slice(&buf[..n]);
809
                let text = String::from_utf8_lossy(&req);
810
                if let Some(end) = text.find("\r\n\r\n") {
811
                    let len = text[..end]
812
                        .lines()
813
                        .find_map(|l| {
814
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
815
                        })
816
                        .unwrap_or(0);
817
                    if req.len() >= end + 4 + len {
818
                        break;
819
                    }
820
                }
821
            }
822
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
823
            let _ = write!(
824
                s,
825
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
826
                body.len()
827
            );
828
        });
829
        (format!("http://{addr}"), rx)
830
    }
831
832
    #[test]
833
    fn registering_binds_the_key_and_keeps_the_credential_private() {
834
        use std::os::unix::fs::PermissionsExt;
835
        let dir = tempfile::tempdir().unwrap();
836
        let home = Home::locate(Some(dir.path())).unwrap();
837
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
838
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
839
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
840
        let req = sent.recv().unwrap();
841
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
842
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
843
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
844
        let key = home.runner_key("plex").unwrap();
845
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
846
        let path = dir.path().join(crate::home::RUNNER_TOML);
847
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
848
        // The runner then starts from runner.toml.
849
        let again = Home::locate(Some(dir.path())).unwrap();
850
        let cfg = again.config.runner.unwrap();
851
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
852
        assert_eq!(cfg.labels, ["gpu"]);
853
    }
854
855
    fn hub_at(base: String) -> Hub {
856
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
857
    }
858
859
    /// oxygenhub#71: a job's result is offered until the hub takes it,
860
    /// through server errors, and a refusal ends the trying at once.
861
    #[test]
862
    fn a_result_is_reported_until_the_hub_takes_it() {
863
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
864
        let quick = Duration::from_millis(10);
865
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
866
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
867
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
868
        let (base, seen) = fake_hub(vec![409]);
869
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
870
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
871
    }
872
873
    #[test]
874
    fn server_errors_are_tried_again_and_client_errors_are_not() {
875
        let quick = [Duration::from_millis(10); 3];
876
        let (base, seen) = fake_hub(vec![500, 503, 204]);
877
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
878
        assert!(r.is_none());
879
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
880
881
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
882
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
883
        assert!(e.contains("HTTP 500"), "{e}");
884
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
885
886
        let (base, seen) = fake_hub(vec![401]);
887
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
888
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
889
    }
890
891
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
892
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
893
    #[test]
894
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
895
        use microvm::Sink;
896
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
897
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
898
        let (base, seen) = fake_hub(vec![204]);
899
        let hub = hub_at(base);
900
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
901
        assert!(!sink.cancelled());
902
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
903
        assert_eq!(n(&seen), 1);
904
905
        let (base, seen) = fake_hub(vec![409]);
906
        let hub = hub_at(base);
907
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
908
        assert!(sink.cancelled());
909
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
910
        sink.flush();
911
        assert!(sink.cancelled());
912
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
913
    }
914
915
    #[test]
916
    fn the_budget_admits_jobs_that_fit() {
917
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
918
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
919
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
920
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
921
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
922
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
923
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
924
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
925
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
926
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
927
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
928
    }
929
}