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