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