OXYGENHUB
# 01043bfff9fd oxygenflow / crates / flow / src / runner.rs
46.4 KiB · Rust · last changed Images by hash: flow rootfs push and pull, and runners fetch what they lack 25 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};
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
4cf0c99a Andy McCall ↶ 315
/// Fetch `claim`'s image from the hub when this home lacks it
316
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
317
/// leaves it missing, and the preflight declines the job as before.
318
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) {
319
    let hash = &claim.sandbox.rootfs;
320
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
321
        return;
322
    }
323
    let note = |text: String| {
324
        eprintln!("job {}: {text}", claim.job_id);
325
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
326
    };
327
    // One fetch of an image at a time: two jobs wanting it wait for one.
328
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
329
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
330
    if home.rootfs(hash).is_file() {
331
        return;
332
    }
333
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
334
        Ok(c) => c,
335
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
336
    };
337
    note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
338
    match client.fetch(home, hash) {
339
        Ok(Some(m)) => note(format!(
340
            "image {}: fetched {} chunks, {} in {:.1} s",
341
            &hash[..12],
342
            m.chunks,
343
            crate::images::human(m.bytes),
344
            m.elapsed.as_secs_f64()
345
        )),
346
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
347
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
348
    }
349
}
350
b11e30f5 Andy McCall ↶ 351
/// Why this runner cannot run `claim` now (spec §11.5), checked before
352
/// anything is fetched: the tier, the architecture, the image and the
353
/// memory. The hub chose it from what the runner last said, which may be
354
/// out of date.
355
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
356
    let sb = &claim.sandbox;
357
    match sb.runtime {
358
        flow_core::Runtime::Microvm => {
359
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
360
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
361
            }
362
            if home.config.microvm.is_none() {
363
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
364
            }
365
        }
366
        flow_core::Runtime::Syscall => {
367
            if let Err(e) = crate::syscall::available() {
368
                return Some(format!("the syscall tier cannot run here: {e}"));
369
            }
370
        }
371
        flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()),
372
    }
373
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
374
}
375
376
/// What stops `sb` running on this home's images, on a machine of
377
/// architecture `here` with `free_mib` of memory free.
378
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
379
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
380
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
381
    }
382
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
383
    let image = home.rootfs(&sb.rootfs);
384
    if !image.is_file() {
385
        return Some(format!("rootfs {short} is not on this runner"));
386
    }
387
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
388
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
389
    }
390
    if let Err(e) = home.verified(&image, &sb.rootfs) {
391
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
392
    }
393
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
394
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
395
    }
396
    None
397
}
398
399
/// `MemAvailable` from /proc/meminfo, in MiB.
400
fn available_memory_mib() -> Option<u64> {
401
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
402
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
403
    Some(kib / 1024)
c5b702e5 Andy McCall ↶ 404
}
405
e0918b88 Andy McCall 406
pub fn serve(home: &Home) -> Result<()> {
407
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
c5b702e5 Andy McCall ↶ 408
    // A runner offers only what it can run: a machine without KVM claims
409
    // nothing rather than taking MicroVM jobs and failing them (#36).
ea56c56e Andy McCall ↶ 410
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
411
    for why in &missing {
412
        eprintln!("flow runner: not offering {why}");
413
    }
e0918b88 Andy McCall 414
    let hub = Hub::new(&cfg)?;
415
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
bb643c25 Andy McCall ↶ 416
    let budget = Budget::of(&cfg);
47c1d64d Andy McCall ↶ 417
    let key = home.runner_key(&name)?;
418
    eprintln!(
c5b702e5 Andy McCall ↶ 419
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
47c1d64d Andy McCall ↶ 420
        cfg.hub,
421
        budget.max_jobs,
c5b702e5 Andy McCall ↶ 422
        runtimes.join(", "),
47c1d64d Andy McCall ↶ 423
        key.identity.hash().to_hex()
424
    );
bb643c25 Andy McCall ↶ 425
    let used = std::sync::Mutex::new(Used::default());
e0918b88 Andy McCall 426
    let mut idle = Duration::from_secs(2);
3007d8f0 Andy McCall ↶ 427
    let pending = report_pending(home);
1daca0b6 Andy McCall ↶ 428
    // The jobs held now, told to the hub at each claim so it can end the
429
    // ones this runner lost: a job is held from its claim until its result
430
    // is reported.
431
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
432
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
3007d8f0 Andy McCall ↶ 433
    std::thread::scope(|scope| {
434
        for (job, f) in pending {
1daca0b6 Andy McCall ↶ 435
            let (hub, held) = (&hub, &held);
3007d8f0 Andy McCall ↶ 436
            scope.spawn(move || {
437
                eprintln!("job {job}: reporting the result kept from before the restart");
438
                report_finish(home, hub, job, &f);
1daca0b6 Andy McCall ↶ 439
                held.lock().unwrap().remove(&job);
3007d8f0 Andy McCall ↶ 440
            });
441
        }
442
        loop {
443
            let room = budget.room(*used.lock().unwrap());
444
            let Some((free_memory_mib, free_cpus)) = room else {
445
                std::thread::sleep(Duration::from_millis(500));
446
                continue;
447
            };
448
            let req = ClaimRequest {
449
                runner: name.clone(),
c5b702e5 Andy McCall ↶ 450
                runtimes: runtimes.clone(),
3007d8f0 Andy McCall ↶ 451
                free_memory_mib,
452
                free_cpus,
453
                info: Some(RunnerInfo {
454
                    version: env!("OXY_VERSION_LONG").into(),
455
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
456
                }),
8c0a4e3a Andy McCall ↶ 457
                // Read each time: an image imported while the runner runs counts.
458
                images: Some(home.images()),
1abadd4a Andy McCall ↶ 459
                image_archs: Some(home.image_archs()),
1daca0b6 Andy McCall ↶ 460
                holding: Some(held.lock().unwrap().iter().copied().collect()),
4cf0c99a Andy McCall ↶ 461
                fetches_images: true,
3007d8f0 Andy McCall ↶ 462
            };
463
            match hub.claim(&req) {
464
                Ok(Some(claim)) => {
465
                    idle = Duration::from_secs(2);
466
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
467
                    {
468
                        let mut u = used.lock().unwrap();
469
                        u.jobs += 1;
470
                        u.memory_mib += mem;
471
                        u.cpus += cpus;
bb643c25 Andy McCall ↶ 472
                    }
1daca0b6 Andy McCall ↶ 473
                    held.lock().unwrap().insert(claim.job_id);
474
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
3007d8f0 Andy McCall ↶ 475
                    scope.spawn(move || {
476
                        let id = claim.job_id;
4cf0c99a Andy McCall ↶ 477
                        fetch_image(home, hub, cfg, &claim);
b11e30f5 Andy McCall ↶ 478
                        if let Some(why) = preflight(home, &claim) {
479
                            eprintln!("job {id}: declined: {why}");
480
                            if let Err(e) = hub.decline(id, &why) {
481
                                eprintln!("job {id}: decline: {e:#}");
482
                            }
483
                            held.lock().unwrap().remove(&id);
484
                            let mut u = used.lock().unwrap();
485
                            u.jobs -= 1;
486
                            u.memory_mib -= mem;
487
                            u.cpus -= cpus;
488
                            return;
489
                        }
3007d8f0 Andy McCall ↶ 490
                        eprintln!(
491
                            "job {id}: {} {} on {}",
492
                            claim.repo,
493
                            claim.job.name,
494
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
495
                        );
496
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
497
                            exit_code: None,
498
                            duration_ms: 0,
499
                            receipt: None,
500
                            signed_receipt: None,
501
                            error: Some(format!("{e:#}")),
502
                        });
503
                        eprintln!(
504
                            "job {id}: exit {:?}{}",
505
                            finish.exit_code,
506
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
507
                        );
280ef951 Andy McCall ↶ 508
                        if finish.error.as_deref() != Some(CANCELLED) {
509
                            report_finish(home, hub, id, &finish);
510
                        }
1daca0b6 Andy McCall ↶ 511
                        held.lock().unwrap().remove(&id);
3007d8f0 Andy McCall ↶ 512
                        let mut u = used.lock().unwrap();
513
                        u.jobs -= 1;
514
                        u.memory_mib -= mem;
515
                        u.cpus -= cpus;
516
                    });
517
                }
518
                Ok(None) => std::thread::sleep(idle),
519
                Err(e) => {
520
                    eprintln!("claim: {e:#}");
521
                    std::thread::sleep(idle);
522
                    idle = (idle * 2).min(Duration::from_secs(60));
523
                }
e0918b88 Andy McCall 524
            }
525
        }
bb643c25 Andy McCall ↶ 526
    })
e0918b88 Andy McCall 527
}
528
280ef951 Andy McCall ↶ 529
/// Batches records to the hub every second (or every 200 lines). A quiet
530
/// job's sink still asks the hub every few seconds whether the job runs,
531
/// with an empty batch; once the hub answers 409 the job is not wanted.
e0918b88 Andy McCall 532
struct LogSink<'a> {
533
    hub: &'a Hub,
534
    job: i64,
535
    buf: Vec<Record>,
536
    last: Instant,
280ef951 Andy McCall ↶ 537
    gone: bool,
538
}
539
540
/// How often a quiet job asks whether it was cancelled.
541
const HEARTBEAT: Duration = Duration::from_secs(5);
542
543
impl microvm::Sink for LogSink<'_> {
544
    fn record(&mut self, r: Record) {
545
        self.push(r)
546
    }
547
548
    fn cancelled(&mut self) -> bool {
549
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
550
            self.send();
551
        }
552
        self.gone
553
    }
e0918b88 Andy McCall 554
}
555
556
impl LogSink<'_> {
557
    fn push(&mut self, mut r: Record) {
280ef951 Andy McCall ↶ 558
        if self.gone {
559
            return;
560
        }
e0918b88 Andy McCall 561
        if r.text.len() > MAX_RECORD_TEXT {
562
            let mut cut = MAX_RECORD_TEXT;
563
            while !r.text.is_char_boundary(cut) {
564
                cut -= 1;
565
            }
566
            r.text.truncate(cut);
567
        }
568
        self.buf.push(r);
569
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
570
            self.flush();
571
        }
572
    }
573
574
    fn flush(&mut self) {
575
        if !self.buf.is_empty() {
280ef951 Andy McCall ↶ 576
            self.send();
577
        }
578
        self.last = Instant::now();
579
    }
580
581
    /// Send what is buffered, even nothing.
582
    fn send(&mut self) {
583
        if self.gone {
584
            return;
585
        }
586
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
587
            Ok(()) => {}
588
            Err(e) if job_gone(&e) => self.gone = true,
589
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
e0918b88 Andy McCall 590
        }
591
        self.last = Instant::now();
592
    }
593
}
bb643c25 Andy McCall ↶ 594
595
/// Held while a job's workspace is prepared (see `run_claim`).
596
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
e0918b88 Andy McCall 597
47c1d64d Andy McCall ↶ 598
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
e0918b88 Andy McCall 599
    let started = Instant::now();
280ef951 Andy McCall ↶ 600
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
e0918b88 Andy McCall 601
    let note =
602
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
603
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
604
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
bb643c25 Andy McCall ↶ 605
    // Preparing a workspace (the cached repositories, the export, the
606
    // image) is one job at a time: fetches into a cached repository must not
607
    // overlap, and the first clone passes its token through the process
608
    // environment. Only the microVMs run side by side.
609
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
e0918b88 Andy McCall 610
    let repo = cached_repo(home, cfg, claim)?;
611
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
612
    let _ = std::fs::remove_dir_all(&scratch);
613
    std::fs::create_dir_all(scratch.join("tree"))?;
614
    let result = (|| -> Result<Finish> {
c23d5191 Andy McCall ↶ 615
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
616
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
617
        // workspace and of the scope hash.
618
        for sub in &claim.subrepos {
619
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
620
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
621
            }
622
            note(
623
                &mut sink,
624
                format!(
625
                    "fetching subrepo {} at {} for {}",
626
                    sub.repo,
627
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
628
                    sub.path
629
                ),
630
            );
631
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
632
            let sub_heads: Vec<ChangeHash> =
633
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
634
            let dir = scratch.join("tree").join(&sub.path);
635
            std::fs::create_dir_all(&dir)?;
636
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
637
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
638
        }
e0918b88 Andy McCall 639
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
ea56c56e Andy McCall ↶ 640
        // The syscall tier runs on the tree itself; a microVM gets an image.
641
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
642
        if !syscall_tier {
643
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
644
            std::fs::remove_dir_all(scratch.join("tree"))?;
645
        }
bb643c25 Andy McCall ↶ 646
        preparing.take();
ea56c56e Andy McCall ↶ 647
        // Cancelled while it was being prepared: no sandbox.
280ef951 Andy McCall ↶ 648
        if microvm::Sink::cancelled(&mut sink) {
649
            return Ok(cancelled());
650
        }
e0918b88 Andy McCall 651
        note(
652
            &mut sink,
653
            format!(
ea56c56e Andy McCall ↶ 654
                "{}: {} {}, {} MiB, rootfs {}",
655
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
e0918b88 Andy McCall 656
                claim.sandbox.cpus,
ea56c56e Andy McCall ↶ 657
                if syscall_tier { "CPU" } else { "vCPU" },
e0918b88 Andy McCall 658
                claim.sandbox.memory_mib,
659
                &claim.sandbox.rootfs[..12]
660
            ),
661
        );
a72abeee Andy McCall ↶ 662
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
ea56c56e Andy McCall ↶ 663
        let timeout = Duration::from_secs(claim.timeout_s);
664
        let outcome = if syscall_tier {
665
            let group = format!("job-{}", claim.job_id);
666
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
667
        } else {
668
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
669
        };
280ef951 Andy McCall ↶ 670
        if outcome.cancelled {
671
            return Ok(cancelled());
672
        }
a67b97e9 Andy McCall ↶ 673
        // A passing job's artifacts, read from its disks and uploaded; a
674
        // declared artifact that cannot be kept fails the job.
675
        let mut outputs = vec![];
676
        let mut output_error = None;
677
        if outcome.exit_code == Some(0) {
678
            for o in &claim.job.outputs {
ea56c56e Andy McCall ↶ 679
                let read = if syscall_tier {
680
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
681
                    crate::outputs::read_dir_file(&dir, &inner)
682
                } else {
683
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
684
                    crate::outputs::read(&image, &inner, &scratch)
685
                };
686
                let kept = read.and_then(|bytes| {
a67b97e9 Andy McCall ↶ 687
                    let blob = Repo::blob_ref(&bytes);
688
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
689
                    if named != blob.to_hex() {
690
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
691
                    }
692
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
693
                    Ok(blob.0)
694
                });
695
                match kept {
696
                    Ok(b) => outputs.push(b),
697
                    Err(e) => {
698
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
699
                        break;
700
                    }
701
                }
702
            }
703
        }
704
        if let Some(e) = &output_error {
705
            note(&mut sink, e.clone());
706
        }
707
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
e0918b88 Andy McCall 708
            job_name: claim.job.name.clone(),
709
            pipeline_hash: hex32(&claim.pipeline_hash),
710
            scope_tree_hash: scope_hash,
711
            inputs: vec![],
712
            environment: outcome.environment.hash(),
a67b97e9 Andy McCall ↶ 713
            outputs,
e0918b88 Andy McCall 714
            exit_code: code as u32,
715
            duration_ms: outcome.duration_ms,
716
        });
47c1d64d Andy McCall ↶ 717
        let signed_receipt = match &receipt {
718
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
719
            None => None,
720
        };
e0918b88 Andy McCall 721
        Ok(Finish {
722
            exit_code: outcome.exit_code,
723
            duration_ms: started.elapsed().as_millis() as u64,
724
            receipt,
47c1d64d Andy McCall ↶ 725
            signed_receipt,
a67b97e9 Andy McCall ↶ 726
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
e0918b88 Andy McCall 727
        })
728
    })();
ea56c56e Andy McCall ↶ 729
    let _ = workspace::remove_tree(&scratch);
e0918b88 Andy McCall 730
    sink.flush();
731
    result
47c1d64d Andy McCall ↶ 732
}
733
734
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
735
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
736
/// the claim, so the runner vouches only for what it fetched.
737
fn sign_receipt(
738
    repo: &Repo,
739
    heads: &[ChangeHash],
740
    cfg: &RunnerConfig,
741
    key: &RunnerKey,
742
    claim: &Claim,
743
    receipt: &Receipt,
744
) -> Result<oxy_core::receipt::SignedReceipt> {
745
    let state = state_of(heads);
746
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
747
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
748
}
749
750
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
751
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
752
    use oxy_core::hash::{hash_object, ObjectType};
753
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
e0918b88 Andy McCall 754
}
755
756
fn hex32(s: &str) -> [u8; 32] {
757
    let mut out = [0; 32];
758
    if let Ok(v) = hex::decode(s) {
759
        if v.len() == 32 {
760
            out.copy_from_slice(&v);
761
        }
762
    }
763
    out
764
}
765
f53e6d47 Andy McCall ↶ 766
/// Whether a network error is worth another try: DNS, a refused or reset
767
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
768
/// hub restarts (oxygenhub#111).
769
fn transient(msg: &str) -> bool {
770
    let m = msg.to_lowercase();
771
    [
772
        "dns failed",
773
        "name resolution",
774
        "connection refused",
775
        "connection reset",
776
        "timed out",
777
        "timeout",
778
        "http 502",
779
        "http 503",
780
        "http 504",
781
        "broken pipe",
782
        "unexpected eof",
783
    ]
784
    .iter()
785
    .any(|k| m.contains(k))
786
}
787
788
/// Run `f` again after a transient network error, waiting `waits` between
789
/// tries (about a minute in all), and give up on any other error at once.
790
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
791
    let mut tries = waits.iter();
792
    loop {
793
        match f() {
794
            Ok(v) => return Ok(v),
795
            Err(e) => match tries.next() {
796
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
797
                _ => return Err(e),
798
            },
799
        }
800
    }
801
}
802
803
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
804
const FETCH_WAITS: [Duration; 5] =
805
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
806
e0918b88 Andy McCall 807
/// The runner's copy of a repository, fetched up to date with the job's token.
808
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
c23d5191 Andy McCall ↶ 809
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
810
}
811
812
/// The runner's copy of `repo`, cloned or fetched with `token`.
813
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
814
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
e0918b88 Andy McCall 815
    let set_remote = |repo: &mut Repo| -> Result<()> {
c23d5191 Andy McCall ↶ 816
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
817
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
e0918b88 Andy McCall 818
        if let Some(ca) = &cfg.ca {
819
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
820
        }
821
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
822
    };
823
    if dir.join(".oxy").is_dir() {
824
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
825
        set_remote(&mut repo)?;
f53e6d47 Andy McCall ↶ 826
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
e0918b88 Andy McCall 827
        return Ok(repo);
828
    }
829
    // First job for this repository: clone with the job's token.
c23d5191 Andy McCall ↶ 830
    std::env::set_var("OXY_TOKEN", token);
e0918b88 Andy McCall 831
    if let Some(ca) = &cfg.ca {
832
        std::env::set_var("OXY_CA", ca);
833
    }
f53e6d47 Andy McCall ↶ 834
    let cloned = with_retries(&FETCH_WAITS, || {
835
        let _ = std::fs::remove_dir_all(&dir);
836
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
837
    });
e0918b88 Andy McCall 838
    std::env::remove_var("OXY_TOKEN");
839
    std::env::remove_var("OXY_CA");
840
    let mut repo = cloned.map_err(|e| {
841
        let _ = std::fs::remove_dir_all(&dir);
c23d5191 Andy McCall ↶ 842
        anyhow::anyhow!("clone {}: {e}", clone_url)
e0918b88 Andy McCall 843
    })?;
844
    set_remote(&mut repo)?;
845
    Ok(repo)
bb643c25 Andy McCall ↶ 846
}
847
848
#[cfg(test)]
849
mod tests {
850
    use super::*;
b11e30f5 Andy McCall ↶ 851
852
    #[test]
853
    fn a_runner_names_what_stops_a_sandbox() {
854
        let dir = tempfile::tempdir().unwrap();
855
        let home = Home::locate(Some(dir.path())).unwrap();
856
        let bytes = b"not really ext4";
857
        let hash = blake3::hash(bytes).to_hex().to_string();
858
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
859
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
860
        }))
861
        .unwrap();
862
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
863
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
864
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
865
        sb.rootfs = hash.clone();
866
        assert_eq!(problem(&sb, Some(4096)), None);
867
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
868
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
869
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
870
        sb.arch = Some("aarch64".into());
871
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
872
        sb.arch = None;
873
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
874
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
875
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
876
    }
f53e6d47 Andy McCall ↶ 877
878
    #[test]
879
    fn transient_network_errors_are_tried_again_and_others_are_not() {
880
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
881
        assert!(transient("HTTP 503: the hub is restarting"));
882
        assert!(!transient("HTTP 404: no such repository"));
883
        let mut n = 0;
884
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
885
            n += 1;
886
            if n < 3 {
887
                Err(anyhow::anyhow!("connection refused"))
888
            } else {
889
                Ok(7)
890
            }
891
        });
892
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
893
        let mut m = 0;
894
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
895
            m += 1;
896
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
897
        });
898
        assert!(got.is_err());
899
        assert_eq!(m, 1, "a refusal is not tried again");
900
        let mut k = 0;
901
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
902
            k += 1;
903
            Err(anyhow::anyhow!("timed out"))
904
        });
905
        assert!(got.is_err());
906
        assert_eq!(k, 3, "gives up after the last wait");
907
    }
1b2229da Andy McCall ↶ 908
909
    /// A hub that answers each request with the next of `codes`, counting them.
910
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
911
        use std::io::{Read, Write};
912
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
913
        let addr = listener.local_addr().unwrap();
914
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
915
        let count = seen.clone();
916
        std::thread::spawn(move || {
917
            for code in codes {
918
                let (mut s, _) = listener.accept().unwrap();
919
                let mut buf = [0u8; 8192];
920
                let _ = s.read(&mut buf);
921
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
922
                let reason = if code == 204 { "No Content" } else { "Error" };
923
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
924
            }
925
        });
926
        (format!("http://{addr}"), seen)
f98a8f78 Andy McCall ↶ 927
    }
928
929
    /// A hub that answers one request with `body` (status 200) and hands
930
    /// back what it was sent.
931
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
932
        use std::io::{Read, Write};
933
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
934
        let addr = listener.local_addr().unwrap();
935
        let (tx, rx) = std::sync::mpsc::channel();
936
        std::thread::spawn(move || {
937
            let (mut s, _) = listener.accept().unwrap();
938
            let mut req = Vec::new();
939
            let mut buf = [0u8; 65536];
940
            loop {
941
                let n = s.read(&mut buf).unwrap_or(0);
942
                if n == 0 {
943
                    break;
944
                }
945
                req.extend_from_slice(&buf[..n]);
946
                let text = String::from_utf8_lossy(&req);
947
                if let Some(end) = text.find("\r\n\r\n") {
948
                    let len = text[..end]
949
                        .lines()
950
                        .find_map(|l| {
951
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
952
                        })
953
                        .unwrap_or(0);
954
                    if req.len() >= end + 4 + len {
955
                        break;
956
                    }
957
                }
958
            }
959
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
960
            let _ = write!(
961
                s,
962
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
963
                body.len()
964
            );
965
        });
966
        (format!("http://{addr}"), rx)
967
    }
968
969
    #[test]
970
    fn registering_binds_the_key_and_keeps_the_credential_private() {
971
        use std::os::unix::fs::PermissionsExt;
972
        let dir = tempfile::tempdir().unwrap();
973
        let home = Home::locate(Some(dir.path())).unwrap();
974
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
975
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
976
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
977
        let req = sent.recv().unwrap();
978
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
979
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
980
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
981
        let key = home.runner_key("plex").unwrap();
982
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
983
        let path = dir.path().join(crate::home::RUNNER_TOML);
984
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
985
        // The runner then starts from runner.toml.
986
        let again = Home::locate(Some(dir.path())).unwrap();
987
        let cfg = again.config.runner.unwrap();
988
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
989
        assert_eq!(cfg.labels, ["gpu"]);
1b2229da Andy McCall ↶ 990
    }
991
992
    fn hub_at(base: String) -> Hub {
993
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
3007d8f0 Andy McCall ↶ 994
    }
995
996
    /// oxygenhub#71: a job's result is offered until the hub takes it,
997
    /// through server errors, and a refusal ends the trying at once.
998
    #[test]
999
    fn a_result_is_reported_until_the_hub_takes_it() {
1000
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
1001
        let quick = Duration::from_millis(10);
1002
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
1003
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
1004
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
1005
        let (base, seen) = fake_hub(vec![409]);
1006
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
1007
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1b2229da Andy McCall ↶ 1008
    }
1009
1010
    #[test]
1011
    fn server_errors_are_tried_again_and_client_errors_are_not() {
1012
        let quick = [Duration::from_millis(10); 3];
1013
        let (base, seen) = fake_hub(vec![500, 503, 204]);
1014
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
1015
        assert!(r.is_none());
1016
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
1017
1018
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
1019
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
1020
        assert!(e.contains("HTTP 500"), "{e}");
1021
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
1022
1023
        let (base, seen) = fake_hub(vec![401]);
1024
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
1025
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
280ef951 Andy McCall ↶ 1026
    }
1027
1028
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
1029
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
1030
    #[test]
1031
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
1032
        use microvm::Sink;
1033
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1034
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
1035
        let (base, seen) = fake_hub(vec![204]);
1036
        let hub = hub_at(base);
1037
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1038
        assert!(!sink.cancelled());
1039
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
1040
        assert_eq!(n(&seen), 1);
1041
1042
        let (base, seen) = fake_hub(vec![409]);
1043
        let hub = hub_at(base);
1044
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1045
        assert!(sink.cancelled());
1046
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
1047
        sink.flush();
1048
        assert!(sink.cancelled());
1049
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1b2229da Andy McCall ↶ 1050
    }
bb643c25 Andy McCall ↶ 1051
1052
    #[test]
1053
    fn the_budget_admits_jobs_that_fit() {
1054
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1055
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1056
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1057
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1058
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1059
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1060
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1061
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1062
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1063
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1064
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1065
    }
e0918b88 Andy McCall 1066
}