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