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
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
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
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.
32
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
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>> {
37
        let url = format!("{}/api/v1{path}", self.base);
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
            }
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
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
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);
145
    }
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()
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()),
177
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
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)?;
204
    }
205
    Ok(got)
206
}
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)];
210
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
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
266
pub fn serve(home: &Home) -> Result<()> {
267
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
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}"))?;
271
    let hub = Hub::new(&cfg)?;
272
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
273
    let budget = Budget::of(&cfg);
274
    let key = home.runner_key(&name)?;
275
    eprintln!(
276
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
277
        cfg.hub,
278
        budget.max_jobs,
279
        runtimes.join(", "),
280
        key.identity.hash().to_hex()
281
    );
282
    let used = std::sync::Mutex::new(Used::default());
283
    let mut idle = Duration::from_secs(2);
284
    let pending = report_pending(home);
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));
290
    std::thread::scope(|scope| {
291
        for (job, f) in pending {
292
            let (hub, held) = (&hub, &held);
293
            scope.spawn(move || {
294
                eprintln!("job {job}: reporting the result kept from before the restart");
295
                report_finish(home, hub, job, &f);
296
                held.lock().unwrap().remove(&job);
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(),
307
                runtimes: runtimes.clone(),
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
                }),
314
                // Read each time: an image imported while the runner runs counts.
315
                images: Some(home.images()),
316
                image_archs: Some(home.image_archs()),
317
                holding: Some(held.lock().unwrap().iter().copied().collect()),
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;
328
                    }
329
                    held.lock().unwrap().insert(claim.job_id);
330
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
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);
352
                        held.lock().unwrap().remove(&id);
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
                }
365
            }
366
        }
367
    })
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
}
402
403
/// Held while a job's workspace is prepared (see `run_claim`).
404
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
405
406
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
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("+")));
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()));
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> {
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
        }
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"))?;
450
        preparing.take();
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
        );
460
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
461
        let outcome = microvm::run(
462
            home,
463
            &claim.sandbox,
464
            &job,
465
            &claim.secrets,
466
            &scratch.join("workspace.ext4"),
467
            &scratch,
468
            Duration::from_secs(claim.timeout_s),
469
            &mut |r| sink.push(r),
470
        )?;
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 {
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(),
505
            outputs,
506
            exit_code: code as u32,
507
            duration_ms: outcome.duration_ms,
508
        });
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
        };
513
        Ok(Finish {
514
            exit_code: outcome.exit_code,
515
            duration_ms: started.elapsed().as_millis() as u64,
516
            receipt,
517
            signed_receipt,
518
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
519
        })
520
    })();
521
    let _ = std::fs::remove_dir_all(&scratch);
522
    sink.flush();
523
    result
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())
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
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
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> {
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('/', "__"));
607
    let set_remote = |repo: &mut Repo| -> Result<()> {
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}"))?;
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)?;
618
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
619
        return Ok(repo);
620
    }
621
    // First job for this repository: clone with the job's token.
622
    std::env::set_var("OXY_TOKEN", token);
623
    if let Some(ca) = &cfg.ca {
624
        std::env::set_var("OXY_CA", ca);
625
    }
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
    });
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);
634
        anyhow::anyhow!("clone {}: {e}", clone_url)
635
    })?;
636
    set_remote(&mut repo)?;
637
    Ok(repo)
638
}
639
640
#[cfg(test)]
641
mod tests {
642
    use super::*;
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
    }
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)
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"]);
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() }
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");
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
    }
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
    }
808
}