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
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
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());
256
    let budget = Budget::of(&cfg);
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
    );
264
    let used = std::sync::Mutex::new(Used::default());
265
    let mut idle = Duration::from_secs(2);
266
    let pending = report_pending(home);
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));
272
    std::thread::scope(|scope| {
273
        for (job, f) in pending {
274
            let (hub, held) = (&hub, &held);
275
            scope.spawn(move || {
276
                eprintln!("job {job}: reporting the result kept from before the restart");
277
                report_finish(home, hub, job, &f);
278
                held.lock().unwrap().remove(&job);
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
                }),
296
                // Read each time: an image imported while the runner runs counts.
297
                images: Some(home.images()),
298
                image_archs: Some(home.image_archs()),
299
                holding: Some(held.lock().unwrap().iter().copied().collect()),
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;
310
                    }
311
                    held.lock().unwrap().insert(claim.job_id);
312
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
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);
334
                        held.lock().unwrap().remove(&id);
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
                }
347
            }
348
        }
349
    })
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
}
384
385
/// Held while a job's workspace is prepared (see `run_claim`).
386
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
387
388
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
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("+")));
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()));
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> {
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
        }
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"))?;
432
        preparing.take();
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
        );
442
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
443
        let outcome = microvm::run(
444
            home,
445
            &claim.sandbox,
446
            &job,
447
            &claim.secrets,
448
            &scratch.join("workspace.ext4"),
449
            &scratch,
450
            Duration::from_secs(claim.timeout_s),
451
            &mut |r| sink.push(r),
452
        )?;
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 {
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(),
487
            outputs,
488
            exit_code: code as u32,
489
            duration_ms: outcome.duration_ms,
490
        });
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
        };
495
        Ok(Finish {
496
            exit_code: outcome.exit_code,
497
            duration_ms: started.elapsed().as_millis() as u64,
498
            receipt,
499
            signed_receipt,
500
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
501
        })
502
    })();
503
    let _ = std::fs::remove_dir_all(&scratch);
504
    sink.flush();
505
    result
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())
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
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
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> {
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('/', "__"));
589
    let set_remote = |repo: &mut Repo| -> Result<()> {
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}"))?;
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)?;
600
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
601
        return Ok(repo);
602
    }
603
    // First job for this repository: clone with the job's token.
604
    std::env::set_var("OXY_TOKEN", token);
605
    if let Some(ca) = &cfg.ca {
606
        std::env::set_var("OXY_CA", ca);
607
    }
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
    });
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);
616
        anyhow::anyhow!("clone {}: {e}", clone_url)
617
    })?;
618
    set_remote(&mut repo)?;
619
    Ok(repo)
620
}
621
622
#[cfg(test)]
623
mod tests {
624
    use super::*;
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
    }
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)
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"]);
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() }
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");
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
    }
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
    }
790
}