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