OXYGENHUB
# 6d53fb7d00ec oxygenflow / crates / flow / src / runner.rs
44.2 KiB · Rust · last changed Report outputs: a directory with report.toml, uploaded file by file with a manifest 27 days 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
280ef951 Andy McCall ↶ 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
e0918b88 Andy McCall 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
1b2229da Andy McCall ↶ 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.
e0918b88 Andy McCall 61
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
1b2229da Andy McCall ↶ 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>> {
e0918b88 Andy McCall 66
        let url = format!("{}/api/v1{path}", self.base);
1b2229da Andy McCall ↶ 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),
280ef951 Andy McCall ↶ 76
                        _ if code < 500 => return Err(anyhow::Error::new(Refused { code, text }).context(url)),
1b2229da Andy McCall ↶ 77
                        _ => bail!("{url}: HTTP {code}: {text}"),
78
                    }
79
                }
80
                Err(e) => bail!("{url}: {e}"),
81
            }
e0918b88 Andy McCall 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
a67b97e9 Andy McCall ↶ 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
6d53fb7d Andy McCall ↶ 126
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
127
    /// the hub keeps it as a blob and answers with its ObjectRef. The
128
    /// report's manifest follows through [`Hub::artifact`].
129
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
130
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
131
        let mut delays = RETRY_DELAYS.iter();
132
        loop {
133
            let req = self
134
                .agent
135
                .post(&url)
136
                .timeout(Duration::from_secs(600))
137
                .set("Authorization", &format!("Bearer {}", self.token))
138
                .set("Content-Type", "application/octet-stream")
139
                .set("X-Flow-Report-Path", path);
140
            match req.send_bytes(bytes) {
141
                Ok(r) => {
142
                    let v: serde_json::Value = r.into_json()?;
143
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
144
                }
145
                Err(ureq::Error::Status(code, r)) => {
146
                    let text = r.into_string().unwrap_or_default();
147
                    match delays.next() {
148
                        Some(d) if code >= 500 => std::thread::sleep(*d),
149
                        _ => bail!("{url}: HTTP {code}: {text}"),
150
                    }
151
                }
152
                Err(e) => bail!("{url}: {e}"),
153
            }
154
        }
155
    }
156
3007d8f0 Andy McCall ↶ 157
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
158
    /// cannot be reached or fails (5xx), try again after `first_wait`,
159
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
160
    /// already, say) is final. A result lost here leaves the job running
161
    /// on the hub until its time limit.
162
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
163
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
164
        let deadline = std::time::Instant::now() + patience;
165
        let mut wait = first_wait;
166
        loop {
167
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
168
                Ok(_) => return Ok(()),
169
                Err(ureq::Error::Status(code, r)) if code < 500 => {
170
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
171
                }
172
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
173
                Err(e) => e.to_string(),
174
            };
175
            if std::time::Instant::now() + wait > deadline {
176
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
177
            }
178
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
179
            std::thread::sleep(wait);
180
            wait = (wait * 2).min(Duration::from_secs(60));
181
        }
182
    }
183
}
184
185
/// How long a runner keeps trying to report a job's end.
186
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
187
188
/// Where a job's result waits until the hub has it, so a runner that
189
/// restarts still reports it.
190
fn pending_path(home: &Home, job: i64) -> PathBuf {
191
    home.root.join("pending").join(format!("{job}.json"))
192
}
193
194
/// Report `f`, keeping it on disk until the hub has it.
195
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
196
    let path = pending_path(home, job);
197
    let kept = std::fs::create_dir_all(path.parent().unwrap())
198
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
199
        .is_ok();
200
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
201
        Ok(()) => {}
202
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
203
    }
204
    if kept {
205
        let _ = std::fs::remove_file(&path);
f98a8f78 Andy McCall ↶ 206
    }
3007d8f0 Andy McCall ↶ 207
}
208
209
/// Results a previous run of the runner could not report, to send again.
210
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
211
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
212
        return Vec::new();
213
    };
214
    dir.filter_map(|e| {
215
        let p = e.ok()?.path();
216
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
217
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
218
        Some((job, f))
219
    })
220
    .collect()
f98a8f78 Andy McCall ↶ 221
}
222
223
/// `flow runner register`: exchange a one-time registration token from
224
/// the hub for this runner's credential, binding the runner's signing key,
225
/// and keep hub, credential and name in runner.toml.
226
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
227
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
228
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
229
    }
230
    let key = home.runner_key(name)?;
231
    let hub = hub.trim_end_matches('/').to_string();
232
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
233
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
234
    let req = Register {
235
        token: token.trim().to_string(),
236
        name: name.to_string(),
237
        identity: hex::encode(key.identity.encode()),
2d025bbf Andy McCall ↶ 238
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
f98a8f78 Andy McCall ↶ 239
    };
240
    let url = format!("{hub}/api/v1/flow/runners/register");
241
    let got: Registered = match agent.post(&url).send_json(&req) {
242
        Ok(r) => r.into_json()?,
243
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
244
        Err(e) => bail!("{url}: {e}"),
245
    };
246
    let mut text = format!(
247
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
248
        got.credential, got.name
249
    );
250
    if let Some(ca) = &ca {
251
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
252
    }
253
    if !labels.is_empty() {
254
        text.push_str(&format!("labels = {labels:?}\n"));
255
    }
256
    let path = home.root.join(crate::home::RUNNER_TOML);
257
    {
258
        use std::io::Write;
259
        use std::os::unix::fs::OpenOptionsExt;
260
        let tmp = path.with_extension("toml.tmp");
261
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
262
        f.write_all(text.as_bytes())?;
263
        f.sync_all()?;
264
        std::fs::rename(&tmp, &path)?;
e0918b88 Andy McCall 265
    }
f98a8f78 Andy McCall ↶ 266
    Ok(got)
e0918b88 Andy McCall 267
}
1b2229da Andy McCall ↶ 268
269
/// Waits before trying a hub request again after a server error.
270
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
e0918b88 Andy McCall 271
bb643c25 Andy McCall ↶ 272
/// What the runner may run at once (flow.toml `[runner]`): at most
273
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
274
#[derive(Debug, Default, PartialEq, Eq)]
275
pub struct Budget {
276
    pub max_jobs: usize,
277
    pub memory_mib: Option<u32>,
278
    pub cpus: Option<u32>,
279
}
280
281
/// What is in use now.
282
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
283
pub struct Used {
284
    pub jobs: usize,
285
    pub memory_mib: u32,
286
    pub cpus: u32,
287
}
288
289
impl Budget {
290
    pub fn of(cfg: &RunnerConfig) -> Budget {
291
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
292
    }
293
294
    /// The room to ask the hub for, or `None` when no job more can start.
295
    /// A lone job may use the whole budget even if it asks for more, so a
296
    /// large sandbox is never starved.
297
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
298
        if used.jobs >= self.max_jobs {
299
            return None;
300
        }
301
        if used.jobs == 0 {
302
            return Some((None, None));
303
        }
304
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
305
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
306
        if mem == Some(0) || cpus == Some(0) {
307
            return None;
308
        }
309
        Some((mem, cpus))
310
    }
311
}
312
c5b702e5 Andy McCall ↶ 313
/// The isolation tiers this machine can honour now (spec §11.1): the
ea56c56e Andy McCall ↶ 314
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
315
/// flow.toml pins Firecracker; the syscall tier when this binary may use
316
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
317
/// `Err` says why there are none; the reasons for missing tiers come back
318
/// with the ones there are.
319
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
320
    let mut have = Vec::new();
321
    let mut missing = Vec::new();
c5b702e5 Andy McCall ↶ 322
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
323
    match (kvm, home.config.microvm.is_some()) {
ea56c56e Andy McCall ↶ 324
        (Ok(_), true) => have.push("microvm".to_string()),
325
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
326
        (Err(e), _) => missing.push(format!(
327
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
c5b702e5 Andy McCall ↶ 328
        )),
329
    }
ea56c56e Andy McCall ↶ 330
    match crate::syscall::available() {
331
        Ok(()) => have.push("syscall".to_string()),
332
        Err(e) => missing.push(format!("syscall: {e}")),
333
    }
334
    if have.is_empty() {
335
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
336
    }
337
    Ok((have, missing))
c5b702e5 Andy McCall ↶ 338
}
339
e0918b88 Andy McCall 340
pub fn serve(home: &Home) -> Result<()> {
341
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
c5b702e5 Andy McCall ↶ 342
    // A runner offers only what it can run: a machine without KVM claims
343
    // nothing rather than taking MicroVM jobs and failing them (#36).
ea56c56e Andy McCall ↶ 344
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
345
    for why in &missing {
346
        eprintln!("flow runner: not offering {why}");
347
    }
e0918b88 Andy McCall 348
    let hub = Hub::new(&cfg)?;
349
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
bb643c25 Andy McCall ↶ 350
    let budget = Budget::of(&cfg);
47c1d64d Andy McCall ↶ 351
    let key = home.runner_key(&name)?;
352
    eprintln!(
c5b702e5 Andy McCall ↶ 353
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
47c1d64d Andy McCall ↶ 354
        cfg.hub,
355
        budget.max_jobs,
c5b702e5 Andy McCall ↶ 356
        runtimes.join(", "),
47c1d64d Andy McCall ↶ 357
        key.identity.hash().to_hex()
358
    );
bb643c25 Andy McCall ↶ 359
    let used = std::sync::Mutex::new(Used::default());
e0918b88 Andy McCall 360
    let mut idle = Duration::from_secs(2);
3007d8f0 Andy McCall ↶ 361
    let pending = report_pending(home);
1daca0b6 Andy McCall ↶ 362
    // The jobs held now, told to the hub at each claim so it can end the
363
    // ones this runner lost: a job is held from its claim until its result
364
    // is reported.
365
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
366
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
3007d8f0 Andy McCall ↶ 367
    std::thread::scope(|scope| {
368
        for (job, f) in pending {
1daca0b6 Andy McCall ↶ 369
            let (hub, held) = (&hub, &held);
3007d8f0 Andy McCall ↶ 370
            scope.spawn(move || {
371
                eprintln!("job {job}: reporting the result kept from before the restart");
372
                report_finish(home, hub, job, &f);
1daca0b6 Andy McCall ↶ 373
                held.lock().unwrap().remove(&job);
3007d8f0 Andy McCall ↶ 374
            });
375
        }
376
        loop {
377
            let room = budget.room(*used.lock().unwrap());
378
            let Some((free_memory_mib, free_cpus)) = room else {
379
                std::thread::sleep(Duration::from_millis(500));
380
                continue;
381
            };
382
            let req = ClaimRequest {
383
                runner: name.clone(),
c5b702e5 Andy McCall ↶ 384
                runtimes: runtimes.clone(),
3007d8f0 Andy McCall ↶ 385
                free_memory_mib,
386
                free_cpus,
387
                info: Some(RunnerInfo {
388
                    version: env!("OXY_VERSION_LONG").into(),
389
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
390
                }),
8c0a4e3a Andy McCall ↶ 391
                // Read each time: an image imported while the runner runs counts.
392
                images: Some(home.images()),
1abadd4a Andy McCall ↶ 393
                image_archs: Some(home.image_archs()),
1daca0b6 Andy McCall ↶ 394
                holding: Some(held.lock().unwrap().iter().copied().collect()),
3007d8f0 Andy McCall ↶ 395
            };
396
            match hub.claim(&req) {
397
                Ok(Some(claim)) => {
398
                    idle = Duration::from_secs(2);
399
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
400
                    {
401
                        let mut u = used.lock().unwrap();
402
                        u.jobs += 1;
403
                        u.memory_mib += mem;
404
                        u.cpus += cpus;
bb643c25 Andy McCall ↶ 405
                    }
1daca0b6 Andy McCall ↶ 406
                    held.lock().unwrap().insert(claim.job_id);
407
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
3007d8f0 Andy McCall ↶ 408
                    scope.spawn(move || {
409
                        let id = claim.job_id;
410
                        eprintln!(
411
                            "job {id}: {} {} on {}",
412
                            claim.repo,
413
                            claim.job.name,
414
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
415
                        );
416
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
417
                            exit_code: None,
418
                            duration_ms: 0,
419
                            receipt: None,
420
                            signed_receipt: None,
421
                            error: Some(format!("{e:#}")),
422
                        });
423
                        eprintln!(
424
                            "job {id}: exit {:?}{}",
425
                            finish.exit_code,
426
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
427
                        );
280ef951 Andy McCall ↶ 428
                        if finish.error.as_deref() != Some(CANCELLED) {
429
                            report_finish(home, hub, id, &finish);
430
                        }
1daca0b6 Andy McCall ↶ 431
                        held.lock().unwrap().remove(&id);
3007d8f0 Andy McCall ↶ 432
                        let mut u = used.lock().unwrap();
433
                        u.jobs -= 1;
434
                        u.memory_mib -= mem;
435
                        u.cpus -= cpus;
436
                    });
437
                }
438
                Ok(None) => std::thread::sleep(idle),
439
                Err(e) => {
440
                    eprintln!("claim: {e:#}");
441
                    std::thread::sleep(idle);
442
                    idle = (idle * 2).min(Duration::from_secs(60));
443
                }
e0918b88 Andy McCall 444
            }
445
        }
bb643c25 Andy McCall ↶ 446
    })
e0918b88 Andy McCall 447
}
448
280ef951 Andy McCall ↶ 449
/// Batches records to the hub every second (or every 200 lines). A quiet
450
/// job's sink still asks the hub every few seconds whether the job runs,
451
/// with an empty batch; once the hub answers 409 the job is not wanted.
e0918b88 Andy McCall 452
struct LogSink<'a> {
453
    hub: &'a Hub,
454
    job: i64,
455
    buf: Vec<Record>,
456
    last: Instant,
280ef951 Andy McCall ↶ 457
    gone: bool,
458
}
459
460
/// How often a quiet job asks whether it was cancelled.
461
const HEARTBEAT: Duration = Duration::from_secs(5);
462
463
impl microvm::Sink for LogSink<'_> {
464
    fn record(&mut self, r: Record) {
465
        self.push(r)
466
    }
467
468
    fn cancelled(&mut self) -> bool {
469
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
470
            self.send();
471
        }
472
        self.gone
473
    }
e0918b88 Andy McCall 474
}
475
476
impl LogSink<'_> {
477
    fn push(&mut self, mut r: Record) {
280ef951 Andy McCall ↶ 478
        if self.gone {
479
            return;
480
        }
e0918b88 Andy McCall 481
        if r.text.len() > MAX_RECORD_TEXT {
482
            let mut cut = MAX_RECORD_TEXT;
483
            while !r.text.is_char_boundary(cut) {
484
                cut -= 1;
485
            }
486
            r.text.truncate(cut);
487
        }
488
        self.buf.push(r);
489
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
490
            self.flush();
491
        }
492
    }
493
494
    fn flush(&mut self) {
495
        if !self.buf.is_empty() {
280ef951 Andy McCall ↶ 496
            self.send();
497
        }
498
        self.last = Instant::now();
499
    }
500
501
    /// Send what is buffered, even nothing.
502
    fn send(&mut self) {
503
        if self.gone {
504
            return;
505
        }
506
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
507
            Ok(()) => {}
508
            Err(e) if job_gone(&e) => self.gone = true,
509
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
e0918b88 Andy McCall 510
        }
511
        self.last = Instant::now();
512
    }
513
}
bb643c25 Andy McCall ↶ 514
515
/// Held while a job's workspace is prepared (see `run_claim`).
516
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
e0918b88 Andy McCall 517
47c1d64d Andy McCall ↶ 518
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
e0918b88 Andy McCall 519
    let started = Instant::now();
280ef951 Andy McCall ↶ 520
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
e0918b88 Andy McCall 521
    let note =
522
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
523
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
524
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
bb643c25 Andy McCall ↶ 525
    // Preparing a workspace (the cached repositories, the export, the
526
    // image) is one job at a time: fetches into a cached repository must not
527
    // overlap, and the first clone passes its token through the process
528
    // environment. Only the microVMs run side by side.
529
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
e0918b88 Andy McCall 530
    let repo = cached_repo(home, cfg, claim)?;
531
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
532
    let _ = std::fs::remove_dir_all(&scratch);
533
    std::fs::create_dir_all(scratch.join("tree"))?;
534
    let result = (|| -> Result<Finish> {
c23d5191 Andy McCall ↶ 535
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
536
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
537
        // workspace and of the scope hash.
538
        for sub in &claim.subrepos {
539
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
540
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
541
            }
542
            note(
543
                &mut sink,
544
                format!(
545
                    "fetching subrepo {} at {} for {}",
546
                    sub.repo,
547
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
548
                    sub.path
549
                ),
550
            );
551
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
552
            let sub_heads: Vec<ChangeHash> =
553
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
554
            let dir = scratch.join("tree").join(&sub.path);
555
            std::fs::create_dir_all(&dir)?;
556
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
557
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
558
        }
e0918b88 Andy McCall 559
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
ea56c56e Andy McCall ↶ 560
        // The syscall tier runs on the tree itself; a microVM gets an image.
561
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
562
        if !syscall_tier {
563
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
564
            std::fs::remove_dir_all(scratch.join("tree"))?;
565
        }
bb643c25 Andy McCall ↶ 566
        preparing.take();
ea56c56e Andy McCall ↶ 567
        // Cancelled while it was being prepared: no sandbox.
280ef951 Andy McCall ↶ 568
        if microvm::Sink::cancelled(&mut sink) {
569
            return Ok(cancelled());
570
        }
e0918b88 Andy McCall 571
        note(
572
            &mut sink,
573
            format!(
ea56c56e Andy McCall ↶ 574
                "{}: {} {}, {} MiB, rootfs {}",
575
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
e0918b88 Andy McCall 576
                claim.sandbox.cpus,
ea56c56e Andy McCall ↶ 577
                if syscall_tier { "CPU" } else { "vCPU" },
e0918b88 Andy McCall 578
                claim.sandbox.memory_mib,
579
                &claim.sandbox.rootfs[..12]
580
            ),
581
        );
a72abeee Andy McCall ↶ 582
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
ea56c56e Andy McCall ↶ 583
        let timeout = Duration::from_secs(claim.timeout_s);
584
        let outcome = if syscall_tier {
585
            let group = format!("job-{}", claim.job_id);
586
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
587
        } else {
588
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
589
        };
280ef951 Andy McCall ↶ 590
        if outcome.cancelled {
591
            return Ok(cancelled());
592
        }
a67b97e9 Andy McCall ↶ 593
        // A passing job's artifacts, read from its disks and uploaded; a
9a6fac2b Andy McCall ↶ 594
        // declared artifact that cannot be kept fails the job. A failing
595
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
596
        // its receipt, and one it cannot keep is only noted.
a67b97e9 Andy McCall ↶ 597
        let mut outputs = vec![];
598
        let mut output_error = None;
9a6fac2b Andy McCall ↶ 599
        let passed = outcome.exit_code == Some(0);
600
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
601
        for o in wanted {
6d53fb7d Andy McCall ↶ 602
            if o.kind == flow_core::OutputKind::Report {
603
                let read = if syscall_tier {
604
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
605
                    crate::outputs::read_report_dir(&dir, &inner)
606
                } else {
607
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
608
                    crate::outputs::read_report(&image, &inner, &scratch)
609
                };
610
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
611
                match kept {
612
                    Ok((blob, files, bytes)) => {
613
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
614
                        let hex = blob.to_hex();
615
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
616
                        if passed {
617
                            outputs.push(blob.0);
618
                        }
619
                    }
620
                    Err(e) if passed => {
621
                        output_error = Some(format!("report {}: {e:#}", o.name));
622
                        break;
623
                    }
624
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
625
                }
626
                continue;
627
            }
9a6fac2b Andy McCall ↶ 628
            let read = if syscall_tier {
629
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
630
                crate::outputs::read_dir_file(&dir, &inner)
631
            } else {
632
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
633
                crate::outputs::read(&image, &inner, &scratch)
634
            };
635
            let kept = read.and_then(|bytes| {
636
                let blob = Repo::blob_ref(&bytes);
637
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
638
                if named != blob.to_hex() {
639
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
640
                }
641
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
642
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
643
                Ok(blob.0)
644
            });
645
            match kept {
646
                Ok(b) if passed => outputs.push(b),
647
                Ok(_) => {}
648
                Err(e) if passed => {
649
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
650
                    break;
a67b97e9 Andy McCall ↶ 651
                }
9a6fac2b Andy McCall ↶ 652
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
a67b97e9 Andy McCall ↶ 653
            }
654
        }
655
        if let Some(e) = &output_error {
656
            note(&mut sink, e.clone());
657
        }
658
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
e0918b88 Andy McCall 659
            job_name: claim.job.name.clone(),
660
            pipeline_hash: hex32(&claim.pipeline_hash),
661
            scope_tree_hash: scope_hash,
662
            inputs: vec![],
663
            environment: outcome.environment.hash(),
a67b97e9 Andy McCall ↶ 664
            outputs,
e0918b88 Andy McCall 665
            exit_code: code as u32,
666
            duration_ms: outcome.duration_ms,
667
        });
47c1d64d Andy McCall ↶ 668
        let signed_receipt = match &receipt {
669
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
670
            None => None,
671
        };
e0918b88 Andy McCall 672
        Ok(Finish {
673
            exit_code: outcome.exit_code,
674
            duration_ms: started.elapsed().as_millis() as u64,
675
            receipt,
47c1d64d Andy McCall ↶ 676
            signed_receipt,
a67b97e9 Andy McCall ↶ 677
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
e0918b88 Andy McCall 678
        })
679
    })();
ea56c56e Andy McCall ↶ 680
    let _ = workspace::remove_tree(&scratch);
e0918b88 Andy McCall 681
    sink.flush();
682
    result
47c1d64d Andy McCall ↶ 683
}
684
685
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
686
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
687
/// the claim, so the runner vouches only for what it fetched.
6d53fb7d Andy McCall ↶ 688
/// Upload a report's files, then its manifest under the report's name, and
689
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
690
/// the manifest's blob, the file count and their total size.
691
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
692
    let mut listed = Vec::with_capacity(files.len());
693
    let mut total = 0u64;
694
    for (path, bytes) in files {
695
        let blob = Repo::blob_ref(bytes).to_hex();
696
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
697
        if named != blob {
698
            bail!("the hub kept {path} as {named}, not {blob}");
699
        }
700
        total += bytes.len() as u64;
701
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
702
    }
703
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
704
    let blob = Repo::blob_ref(&manifest);
705
    let named = hub.artifact(job, name, &manifest)?;
706
    if named != blob.to_hex() {
707
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
708
    }
709
    Ok((blob, files.len(), total))
710
}
711
47c1d64d Andy McCall ↶ 712
fn sign_receipt(
713
    repo: &Repo,
714
    heads: &[ChangeHash],
715
    cfg: &RunnerConfig,
716
    key: &RunnerKey,
717
    claim: &Claim,
718
    receipt: &Receipt,
719
) -> Result<oxy_core::receipt::SignedReceipt> {
720
    let state = state_of(heads);
721
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
722
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
723
}
724
725
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
726
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
727
    use oxy_core::hash::{hash_object, ObjectType};
728
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
e0918b88 Andy McCall 729
}
730
731
fn hex32(s: &str) -> [u8; 32] {
732
    let mut out = [0; 32];
733
    if let Ok(v) = hex::decode(s) {
734
        if v.len() == 32 {
735
            out.copy_from_slice(&v);
736
        }
737
    }
738
    out
739
}
740
f53e6d47 Andy McCall ↶ 741
/// Whether a network error is worth another try: DNS, a refused or reset
742
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
743
/// hub restarts (oxygenhub#111).
744
fn transient(msg: &str) -> bool {
745
    let m = msg.to_lowercase();
746
    [
747
        "dns failed",
748
        "name resolution",
749
        "connection refused",
750
        "connection reset",
751
        "timed out",
752
        "timeout",
753
        "http 502",
754
        "http 503",
755
        "http 504",
756
        "broken pipe",
757
        "unexpected eof",
758
    ]
759
    .iter()
760
    .any(|k| m.contains(k))
761
}
762
763
/// Run `f` again after a transient network error, waiting `waits` between
764
/// tries (about a minute in all), and give up on any other error at once.
765
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
766
    let mut tries = waits.iter();
767
    loop {
768
        match f() {
769
            Ok(v) => return Ok(v),
770
            Err(e) => match tries.next() {
771
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
772
                _ => return Err(e),
773
            },
774
        }
775
    }
776
}
777
778
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
779
const FETCH_WAITS: [Duration; 5] =
780
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
781
e0918b88 Andy McCall 782
/// The runner's copy of a repository, fetched up to date with the job's token.
783
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
c23d5191 Andy McCall ↶ 784
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
785
}
786
787
/// The runner's copy of `repo`, cloned or fetched with `token`.
788
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
789
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
e0918b88 Andy McCall 790
    let set_remote = |repo: &mut Repo| -> Result<()> {
c23d5191 Andy McCall ↶ 791
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
792
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
e0918b88 Andy McCall 793
        if let Some(ca) = &cfg.ca {
794
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
795
        }
796
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
797
    };
798
    if dir.join(".oxy").is_dir() {
799
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
800
        set_remote(&mut repo)?;
f53e6d47 Andy McCall ↶ 801
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
e0918b88 Andy McCall 802
        return Ok(repo);
803
    }
804
    // First job for this repository: clone with the job's token.
c23d5191 Andy McCall ↶ 805
    std::env::set_var("OXY_TOKEN", token);
e0918b88 Andy McCall 806
    if let Some(ca) = &cfg.ca {
807
        std::env::set_var("OXY_CA", ca);
808
    }
f53e6d47 Andy McCall ↶ 809
    let cloned = with_retries(&FETCH_WAITS, || {
810
        let _ = std::fs::remove_dir_all(&dir);
811
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
812
    });
e0918b88 Andy McCall 813
    std::env::remove_var("OXY_TOKEN");
814
    std::env::remove_var("OXY_CA");
815
    let mut repo = cloned.map_err(|e| {
816
        let _ = std::fs::remove_dir_all(&dir);
c23d5191 Andy McCall ↶ 817
        anyhow::anyhow!("clone {}: {e}", clone_url)
e0918b88 Andy McCall 818
    })?;
819
    set_remote(&mut repo)?;
820
    Ok(repo)
bb643c25 Andy McCall ↶ 821
}
822
823
#[cfg(test)]
824
mod tests {
825
    use super::*;
f53e6d47 Andy McCall ↶ 826
827
    #[test]
828
    fn transient_network_errors_are_tried_again_and_others_are_not() {
829
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
830
        assert!(transient("HTTP 503: the hub is restarting"));
831
        assert!(!transient("HTTP 404: no such repository"));
832
        let mut n = 0;
833
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
834
            n += 1;
835
            if n < 3 {
836
                Err(anyhow::anyhow!("connection refused"))
837
            } else {
838
                Ok(7)
839
            }
840
        });
841
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
842
        let mut m = 0;
843
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
844
            m += 1;
845
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
846
        });
847
        assert!(got.is_err());
848
        assert_eq!(m, 1, "a refusal is not tried again");
849
        let mut k = 0;
850
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
851
            k += 1;
852
            Err(anyhow::anyhow!("timed out"))
853
        });
854
        assert!(got.is_err());
855
        assert_eq!(k, 3, "gives up after the last wait");
856
    }
1b2229da Andy McCall ↶ 857
858
    /// A hub that answers each request with the next of `codes`, counting them.
859
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
860
        use std::io::{Read, Write};
861
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
862
        let addr = listener.local_addr().unwrap();
863
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
864
        let count = seen.clone();
865
        std::thread::spawn(move || {
866
            for code in codes {
867
                let (mut s, _) = listener.accept().unwrap();
868
                let mut buf = [0u8; 8192];
869
                let _ = s.read(&mut buf);
870
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
871
                let reason = if code == 204 { "No Content" } else { "Error" };
872
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
873
            }
874
        });
875
        (format!("http://{addr}"), seen)
f98a8f78 Andy McCall ↶ 876
    }
877
878
    /// A hub that answers one request with `body` (status 200) and hands
879
    /// back what it was sent.
880
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
881
        use std::io::{Read, Write};
882
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
883
        let addr = listener.local_addr().unwrap();
884
        let (tx, rx) = std::sync::mpsc::channel();
885
        std::thread::spawn(move || {
886
            let (mut s, _) = listener.accept().unwrap();
887
            let mut req = Vec::new();
888
            let mut buf = [0u8; 65536];
889
            loop {
890
                let n = s.read(&mut buf).unwrap_or(0);
891
                if n == 0 {
892
                    break;
893
                }
894
                req.extend_from_slice(&buf[..n]);
895
                let text = String::from_utf8_lossy(&req);
896
                if let Some(end) = text.find("\r\n\r\n") {
897
                    let len = text[..end]
898
                        .lines()
899
                        .find_map(|l| {
900
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
901
                        })
902
                        .unwrap_or(0);
903
                    if req.len() >= end + 4 + len {
904
                        break;
905
                    }
906
                }
907
            }
908
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
909
            let _ = write!(
910
                s,
911
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
912
                body.len()
913
            );
914
        });
915
        (format!("http://{addr}"), rx)
916
    }
917
918
    #[test]
919
    fn registering_binds_the_key_and_keeps_the_credential_private() {
920
        use std::os::unix::fs::PermissionsExt;
921
        let dir = tempfile::tempdir().unwrap();
922
        let home = Home::locate(Some(dir.path())).unwrap();
923
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
924
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
925
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
926
        let req = sent.recv().unwrap();
927
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
928
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
929
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
930
        let key = home.runner_key("plex").unwrap();
931
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
932
        let path = dir.path().join(crate::home::RUNNER_TOML);
933
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
934
        // The runner then starts from runner.toml.
935
        let again = Home::locate(Some(dir.path())).unwrap();
936
        let cfg = again.config.runner.unwrap();
937
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
938
        assert_eq!(cfg.labels, ["gpu"]);
1b2229da Andy McCall ↶ 939
    }
940
941
    fn hub_at(base: String) -> Hub {
942
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
3007d8f0 Andy McCall ↶ 943
    }
944
945
    /// oxygenhub#71: a job's result is offered until the hub takes it,
946
    /// through server errors, and a refusal ends the trying at once.
947
    #[test]
948
    fn a_result_is_reported_until_the_hub_takes_it() {
949
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
950
        let quick = Duration::from_millis(10);
951
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
952
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
953
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
954
        let (base, seen) = fake_hub(vec![409]);
955
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
956
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1b2229da Andy McCall ↶ 957
    }
958
959
    #[test]
960
    fn server_errors_are_tried_again_and_client_errors_are_not() {
961
        let quick = [Duration::from_millis(10); 3];
962
        let (base, seen) = fake_hub(vec![500, 503, 204]);
963
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
964
        assert!(r.is_none());
965
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
966
967
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
968
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
969
        assert!(e.contains("HTTP 500"), "{e}");
970
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
971
972
        let (base, seen) = fake_hub(vec![401]);
973
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
974
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
280ef951 Andy McCall ↶ 975
    }
976
977
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
978
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
979
    #[test]
980
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
981
        use microvm::Sink;
982
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
983
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
984
        let (base, seen) = fake_hub(vec![204]);
985
        let hub = hub_at(base);
986
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
987
        assert!(!sink.cancelled());
988
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
989
        assert_eq!(n(&seen), 1);
990
991
        let (base, seen) = fake_hub(vec![409]);
992
        let hub = hub_at(base);
993
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
994
        assert!(sink.cancelled());
995
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
996
        sink.flush();
997
        assert!(sink.cancelled());
998
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1b2229da Andy McCall ↶ 999
    }
bb643c25 Andy McCall ↶ 1000
1001
    #[test]
1002
    fn the_budget_admits_jobs_that_fit() {
1003
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1004
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1005
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1006
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1007
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1008
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1009
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1010
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1011
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1012
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1013
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1014
    }
e0918b88 Andy McCall 1015
}