OXYGENHUB
# f53e6d47b56a oxygenflow / crates / flow / src / runner.rs
34.0 KiB · Rust · last changed A runner tries a fetch again after a network hiccup 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
e0918b88 Andy McCall 252
pub fn serve(home: &Home) -> Result<()> {
253
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
254
    let hub = Hub::new(&cfg)?;
255
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
bb643c25 Andy McCall ↶ 256
    let budget = Budget::of(&cfg);
47c1d64d Andy McCall ↶ 257
    let key = home.runner_key(&name)?;
258
    eprintln!(
259
        "flow runner {name}: polling {} (up to {} jobs at once), signing receipts as {}",
260
        cfg.hub,
261
        budget.max_jobs,
262
        key.identity.hash().to_hex()
263
    );
bb643c25 Andy McCall ↶ 264
    let used = std::sync::Mutex::new(Used::default());
e0918b88 Andy McCall 265
    let mut idle = Duration::from_secs(2);
3007d8f0 Andy McCall ↶ 266
    let pending = report_pending(home);
1daca0b6 Andy McCall ↶ 267
    // The jobs held now, told to the hub at each claim so it can end the
268
    // ones this runner lost: a job is held from its claim until its result
269
    // is reported.
270
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
271
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
3007d8f0 Andy McCall ↶ 272
    std::thread::scope(|scope| {
273
        for (job, f) in pending {
1daca0b6 Andy McCall ↶ 274
            let (hub, held) = (&hub, &held);
3007d8f0 Andy McCall ↶ 275
            scope.spawn(move || {
276
                eprintln!("job {job}: reporting the result kept from before the restart");
277
                report_finish(home, hub, job, &f);
1daca0b6 Andy McCall ↶ 278
                held.lock().unwrap().remove(&job);
3007d8f0 Andy McCall ↶ 279
            });
280
        }
281
        loop {
282
            let room = budget.room(*used.lock().unwrap());
283
            let Some((free_memory_mib, free_cpus)) = room else {
284
                std::thread::sleep(Duration::from_millis(500));
285
                continue;
286
            };
287
            let req = ClaimRequest {
288
                runner: name.clone(),
289
                runtimes: vec!["microvm".into()],
290
                free_memory_mib,
291
                free_cpus,
292
                info: Some(RunnerInfo {
293
                    version: env!("OXY_VERSION_LONG").into(),
294
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
295
                }),
8c0a4e3a Andy McCall ↶ 296
                // Read each time: an image imported while the runner runs counts.
297
                images: Some(home.images()),
1abadd4a Andy McCall ↶ 298
                image_archs: Some(home.image_archs()),
1daca0b6 Andy McCall ↶ 299
                holding: Some(held.lock().unwrap().iter().copied().collect()),
3007d8f0 Andy McCall ↶ 300
            };
301
            match hub.claim(&req) {
302
                Ok(Some(claim)) => {
303
                    idle = Duration::from_secs(2);
304
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
305
                    {
306
                        let mut u = used.lock().unwrap();
307
                        u.jobs += 1;
308
                        u.memory_mib += mem;
309
                        u.cpus += cpus;
bb643c25 Andy McCall ↶ 310
                    }
1daca0b6 Andy McCall ↶ 311
                    held.lock().unwrap().insert(claim.job_id);
312
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
3007d8f0 Andy McCall ↶ 313
                    scope.spawn(move || {
314
                        let id = claim.job_id;
315
                        eprintln!(
316
                            "job {id}: {} {} on {}",
317
                            claim.repo,
318
                            claim.job.name,
319
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
320
                        );
321
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
322
                            exit_code: None,
323
                            duration_ms: 0,
324
                            receipt: None,
325
                            signed_receipt: None,
326
                            error: Some(format!("{e:#}")),
327
                        });
328
                        eprintln!(
329
                            "job {id}: exit {:?}{}",
330
                            finish.exit_code,
331
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
332
                        );
333
                        report_finish(home, hub, id, &finish);
1daca0b6 Andy McCall ↶ 334
                        held.lock().unwrap().remove(&id);
3007d8f0 Andy McCall ↶ 335
                        let mut u = used.lock().unwrap();
336
                        u.jobs -= 1;
337
                        u.memory_mib -= mem;
338
                        u.cpus -= cpus;
339
                    });
340
                }
341
                Ok(None) => std::thread::sleep(idle),
342
                Err(e) => {
343
                    eprintln!("claim: {e:#}");
344
                    std::thread::sleep(idle);
345
                    idle = (idle * 2).min(Duration::from_secs(60));
346
                }
e0918b88 Andy McCall 347
            }
348
        }
bb643c25 Andy McCall ↶ 349
    })
e0918b88 Andy McCall 350
}
351
352
/// Batches records to the hub every second (or every 200 lines).
353
struct LogSink<'a> {
354
    hub: &'a Hub,
355
    job: i64,
356
    buf: Vec<Record>,
357
    last: Instant,
358
}
359
360
impl LogSink<'_> {
361
    fn push(&mut self, mut r: Record) {
362
        if r.text.len() > MAX_RECORD_TEXT {
363
            let mut cut = MAX_RECORD_TEXT;
364
            while !r.text.is_char_boundary(cut) {
365
                cut -= 1;
366
            }
367
            r.text.truncate(cut);
368
        }
369
        self.buf.push(r);
370
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
371
            self.flush();
372
        }
373
    }
374
375
    fn flush(&mut self) {
376
        if !self.buf.is_empty() {
377
            if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) {
378
                eprintln!("job {}: log: {e:#}", self.job);
379
            }
380
        }
381
        self.last = Instant::now();
382
    }
383
}
bb643c25 Andy McCall ↶ 384
385
/// Held while a job's workspace is prepared (see `run_claim`).
386
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
e0918b88 Andy McCall 387
47c1d64d Andy McCall ↶ 388
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
e0918b88 Andy McCall 389
    let started = Instant::now();
390
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() };
391
    let note =
392
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
393
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
394
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
bb643c25 Andy McCall ↶ 395
    // Preparing a workspace (the cached repositories, the export, the
396
    // image) is one job at a time: fetches into a cached repository must not
397
    // overlap, and the first clone passes its token through the process
398
    // environment. Only the microVMs run side by side.
399
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
e0918b88 Andy McCall 400
    let repo = cached_repo(home, cfg, claim)?;
401
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
402
    let _ = std::fs::remove_dir_all(&scratch);
403
    std::fs::create_dir_all(scratch.join("tree"))?;
404
    let result = (|| -> Result<Finish> {
c23d5191 Andy McCall ↶ 405
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
406
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
407
        // workspace and of the scope hash.
408
        for sub in &claim.subrepos {
409
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
410
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
411
            }
412
            note(
413
                &mut sink,
414
                format!(
415
                    "fetching subrepo {} at {} for {}",
416
                    sub.repo,
417
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
418
                    sub.path
419
                ),
420
            );
421
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
422
            let sub_heads: Vec<ChangeHash> =
423
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
424
            let dir = scratch.join("tree").join(&sub.path);
425
            std::fs::create_dir_all(&dir)?;
426
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
427
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
428
        }
e0918b88 Andy McCall 429
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
430
        workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
431
        std::fs::remove_dir_all(scratch.join("tree"))?;
bb643c25 Andy McCall ↶ 432
        preparing.take();
e0918b88 Andy McCall 433
        note(
434
            &mut sink,
435
            format!(
436
                "booting a microVM: {} vCPU, {} MiB, rootfs {}",
437
                claim.sandbox.cpus,
438
                claim.sandbox.memory_mib,
439
                &claim.sandbox.rootfs[..12]
440
            ),
441
        );
a72abeee Andy McCall ↶ 442
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
e0918b88 Andy McCall 443
        let outcome = microvm::run(
444
            home,
445
            &claim.sandbox,
c4285535 Andy McCall ↶ 446
            &job,
e2c12561 Andy McCall ↶ 447
            &claim.secrets,
e0918b88 Andy McCall 448
            &scratch.join("workspace.ext4"),
449
            &scratch,
450
            Duration::from_secs(claim.timeout_s),
451
            &mut |r| sink.push(r),
452
        )?;
a67b97e9 Andy McCall ↶ 453
        // A passing job's artifacts, read from its disks and uploaded; a
454
        // declared artifact that cannot be kept fails the job.
455
        let mut outputs = vec![];
456
        let mut output_error = None;
457
        if outcome.exit_code == Some(0) {
458
            for o in &claim.job.outputs {
459
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
460
                let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| {
461
                    let blob = Repo::blob_ref(&bytes);
462
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
463
                    if named != blob.to_hex() {
464
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
465
                    }
466
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
467
                    Ok(blob.0)
468
                });
469
                match kept {
470
                    Ok(b) => outputs.push(b),
471
                    Err(e) => {
472
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
473
                        break;
474
                    }
475
                }
476
            }
477
        }
478
        if let Some(e) = &output_error {
479
            note(&mut sink, e.clone());
480
        }
481
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
e0918b88 Andy McCall 482
            job_name: claim.job.name.clone(),
483
            pipeline_hash: hex32(&claim.pipeline_hash),
484
            scope_tree_hash: scope_hash,
485
            inputs: vec![],
486
            environment: outcome.environment.hash(),
a67b97e9 Andy McCall ↶ 487
            outputs,
e0918b88 Andy McCall 488
            exit_code: code as u32,
489
            duration_ms: outcome.duration_ms,
490
        });
47c1d64d Andy McCall ↶ 491
        let signed_receipt = match &receipt {
492
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
493
            None => None,
494
        };
e0918b88 Andy McCall 495
        Ok(Finish {
496
            exit_code: outcome.exit_code,
497
            duration_ms: started.elapsed().as_millis() as u64,
498
            receipt,
47c1d64d Andy McCall ↶ 499
            signed_receipt,
a67b97e9 Andy McCall ↶ 500
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
e0918b88 Andy McCall 501
        })
502
    })();
503
    let _ = std::fs::remove_dir_all(&scratch);
504
    sink.flush();
505
    result
47c1d64d Andy McCall ↶ 506
}
507
508
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
509
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
510
/// the claim, so the runner vouches only for what it fetched.
511
fn sign_receipt(
512
    repo: &Repo,
513
    heads: &[ChangeHash],
514
    cfg: &RunnerConfig,
515
    key: &RunnerKey,
516
    claim: &Claim,
517
    receipt: &Receipt,
518
) -> Result<oxy_core::receipt::SignedReceipt> {
519
    let state = state_of(heads);
520
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
521
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
522
}
523
524
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
525
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
526
    use oxy_core::hash::{hash_object, ObjectType};
527
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
e0918b88 Andy McCall 528
}
529
530
fn hex32(s: &str) -> [u8; 32] {
531
    let mut out = [0; 32];
532
    if let Ok(v) = hex::decode(s) {
533
        if v.len() == 32 {
534
            out.copy_from_slice(&v);
535
        }
536
    }
537
    out
538
}
539
f53e6d47 Andy McCall ↶ 540
/// Whether a network error is worth another try: DNS, a refused or reset
541
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
542
/// hub restarts (oxygenhub#111).
543
fn transient(msg: &str) -> bool {
544
    let m = msg.to_lowercase();
545
    [
546
        "dns failed",
547
        "name resolution",
548
        "connection refused",
549
        "connection reset",
550
        "timed out",
551
        "timeout",
552
        "http 502",
553
        "http 503",
554
        "http 504",
555
        "broken pipe",
556
        "unexpected eof",
557
    ]
558
    .iter()
559
    .any(|k| m.contains(k))
560
}
561
562
/// Run `f` again after a transient network error, waiting `waits` between
563
/// tries (about a minute in all), and give up on any other error at once.
564
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
565
    let mut tries = waits.iter();
566
    loop {
567
        match f() {
568
            Ok(v) => return Ok(v),
569
            Err(e) => match tries.next() {
570
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
571
                _ => return Err(e),
572
            },
573
        }
574
    }
575
}
576
577
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
578
const FETCH_WAITS: [Duration; 5] =
579
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
580
e0918b88 Andy McCall 581
/// The runner's copy of a repository, fetched up to date with the job's token.
582
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
c23d5191 Andy McCall ↶ 583
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
584
}
585
586
/// The runner's copy of `repo`, cloned or fetched with `token`.
587
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
588
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
e0918b88 Andy McCall 589
    let set_remote = |repo: &mut Repo| -> Result<()> {
c23d5191 Andy McCall ↶ 590
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
591
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
e0918b88 Andy McCall 592
        if let Some(ca) = &cfg.ca {
593
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
594
        }
595
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
596
    };
597
    if dir.join(".oxy").is_dir() {
598
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
599
        set_remote(&mut repo)?;
f53e6d47 Andy McCall ↶ 600
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
e0918b88 Andy McCall 601
        return Ok(repo);
602
    }
603
    // First job for this repository: clone with the job's token.
c23d5191 Andy McCall ↶ 604
    std::env::set_var("OXY_TOKEN", token);
e0918b88 Andy McCall 605
    if let Some(ca) = &cfg.ca {
606
        std::env::set_var("OXY_CA", ca);
607
    }
f53e6d47 Andy McCall ↶ 608
    let cloned = with_retries(&FETCH_WAITS, || {
609
        let _ = std::fs::remove_dir_all(&dir);
610
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
611
    });
e0918b88 Andy McCall 612
    std::env::remove_var("OXY_TOKEN");
613
    std::env::remove_var("OXY_CA");
614
    let mut repo = cloned.map_err(|e| {
615
        let _ = std::fs::remove_dir_all(&dir);
c23d5191 Andy McCall ↶ 616
        anyhow::anyhow!("clone {}: {e}", clone_url)
e0918b88 Andy McCall 617
    })?;
618
    set_remote(&mut repo)?;
619
    Ok(repo)
bb643c25 Andy McCall ↶ 620
}
621
622
#[cfg(test)]
623
mod tests {
624
    use super::*;
f53e6d47 Andy McCall ↶ 625
626
    #[test]
627
    fn transient_network_errors_are_tried_again_and_others_are_not() {
628
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
629
        assert!(transient("HTTP 503: the hub is restarting"));
630
        assert!(!transient("HTTP 404: no such repository"));
631
        let mut n = 0;
632
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
633
            n += 1;
634
            if n < 3 {
635
                Err(anyhow::anyhow!("connection refused"))
636
            } else {
637
                Ok(7)
638
            }
639
        });
640
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
641
        let mut m = 0;
642
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
643
            m += 1;
644
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
645
        });
646
        assert!(got.is_err());
647
        assert_eq!(m, 1, "a refusal is not tried again");
648
        let mut k = 0;
649
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
650
            k += 1;
651
            Err(anyhow::anyhow!("timed out"))
652
        });
653
        assert!(got.is_err());
654
        assert_eq!(k, 3, "gives up after the last wait");
655
    }
1b2229da Andy McCall ↶ 656
657
    /// A hub that answers each request with the next of `codes`, counting them.
658
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
659
        use std::io::{Read, Write};
660
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
661
        let addr = listener.local_addr().unwrap();
662
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
663
        let count = seen.clone();
664
        std::thread::spawn(move || {
665
            for code in codes {
666
                let (mut s, _) = listener.accept().unwrap();
667
                let mut buf = [0u8; 8192];
668
                let _ = s.read(&mut buf);
669
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
670
                let reason = if code == 204 { "No Content" } else { "Error" };
671
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
672
            }
673
        });
674
        (format!("http://{addr}"), seen)
f98a8f78 Andy McCall ↶ 675
    }
676
677
    /// A hub that answers one request with `body` (status 200) and hands
678
    /// back what it was sent.
679
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
680
        use std::io::{Read, Write};
681
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
682
        let addr = listener.local_addr().unwrap();
683
        let (tx, rx) = std::sync::mpsc::channel();
684
        std::thread::spawn(move || {
685
            let (mut s, _) = listener.accept().unwrap();
686
            let mut req = Vec::new();
687
            let mut buf = [0u8; 65536];
688
            loop {
689
                let n = s.read(&mut buf).unwrap_or(0);
690
                if n == 0 {
691
                    break;
692
                }
693
                req.extend_from_slice(&buf[..n]);
694
                let text = String::from_utf8_lossy(&req);
695
                if let Some(end) = text.find("\r\n\r\n") {
696
                    let len = text[..end]
697
                        .lines()
698
                        .find_map(|l| {
699
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
700
                        })
701
                        .unwrap_or(0);
702
                    if req.len() >= end + 4 + len {
703
                        break;
704
                    }
705
                }
706
            }
707
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
708
            let _ = write!(
709
                s,
710
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
711
                body.len()
712
            );
713
        });
714
        (format!("http://{addr}"), rx)
715
    }
716
717
    #[test]
718
    fn registering_binds_the_key_and_keeps_the_credential_private() {
719
        use std::os::unix::fs::PermissionsExt;
720
        let dir = tempfile::tempdir().unwrap();
721
        let home = Home::locate(Some(dir.path())).unwrap();
722
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
723
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
724
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
725
        let req = sent.recv().unwrap();
726
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
727
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
728
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
729
        let key = home.runner_key("plex").unwrap();
730
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
731
        let path = dir.path().join(crate::home::RUNNER_TOML);
732
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
733
        // The runner then starts from runner.toml.
734
        let again = Home::locate(Some(dir.path())).unwrap();
735
        let cfg = again.config.runner.unwrap();
736
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
737
        assert_eq!(cfg.labels, ["gpu"]);
1b2229da Andy McCall ↶ 738
    }
739
740
    fn hub_at(base: String) -> Hub {
741
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
3007d8f0 Andy McCall ↶ 742
    }
743
744
    /// oxygenhub#71: a job's result is offered until the hub takes it,
745
    /// through server errors, and a refusal ends the trying at once.
746
    #[test]
747
    fn a_result_is_reported_until_the_hub_takes_it() {
748
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
749
        let quick = Duration::from_millis(10);
750
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
751
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
752
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
753
        let (base, seen) = fake_hub(vec![409]);
754
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
755
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1b2229da Andy McCall ↶ 756
    }
757
758
    #[test]
759
    fn server_errors_are_tried_again_and_client_errors_are_not() {
760
        let quick = [Duration::from_millis(10); 3];
761
        let (base, seen) = fake_hub(vec![500, 503, 204]);
762
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
763
        assert!(r.is_none());
764
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
765
766
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
767
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
768
        assert!(e.contains("HTTP 500"), "{e}");
769
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
770
771
        let (base, seen) = fake_hub(vec![401]);
772
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
773
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
774
    }
bb643c25 Andy McCall ↶ 775
776
    #[test]
777
    fn the_budget_admits_jobs_that_fit() {
778
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
779
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
780
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
781
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
782
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
783
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
784
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
785
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
786
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
787
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
788
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
789
    }
e0918b88 Andy McCall 790
}