OXYGENHUB
# ed4aef8b70ca oxygenflow / crates / flow / src / runner.rs
65.7 KiB · Rust · last changed Runner: lock fetches per repository, not the whole runner 19 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 {
8f211e20 Andy McCall ↶ 42
    Finish {
43
        exit_code: None,
44
        duration_ms: 0,
45
        receipt: None,
46
        signed_receipt: None,
47
        error: Some(CANCELLED.into()),
48
        peak_disk_mib: None,
49
        peak_memory_mib: None,
50
    }
280ef951 Andy McCall ↶ 51
}
52
e0918b88 Andy McCall 53
pub struct Hub {
54
    base: String,
55
    token: String,
56
    agent: ureq::Agent,
57
}
58
59
impl Hub {
60
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
61
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
62
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
63
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
64
    }
65
1b2229da Andy McCall ↶ 66
    /// POST to the hub. A server error (5xx) means nothing was committed,
67
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
68
    /// busy hub costs a job a few seconds, not its log lines or its claim.
e0918b88 Andy McCall 69
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
1b2229da Andy McCall ↶ 70
        self.post_with(path, body, &RETRY_DELAYS)
71
    }
72
73
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
e0918b88 Andy McCall 74
        let url = format!("{}/api/v1{path}", self.base);
89171fca Andy McCall ↶ 75
        let r = retrying(&url, delays, false, || {
76
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
77
        })?;
78
        Ok(if r.status() == 204 { None } else { Some(r) })
79
    }
80
81
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
82
    /// away for a few minutes, as while a deploy restarts it, so a job's
83
    /// declines and uploads outlast it.
84
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
85
        let url = format!("{}/api/v1{path}", self.base);
86
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
87
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
88
        })?;
89
        Ok(if r.status() == 204 { None } else { Some(r) })
e0918b88 Andy McCall 90
    }
91
92
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
93
        match self.post("/flow/claim", req)? {
94
            Some(r) => Ok(Some(r.into_json()?)),
95
            None => Ok(None),
96
        }
97
    }
98
b11e30f5 Andy McCall ↶ 99
    /// Hand job `job` back, saying why this runner will not run it.
100
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
89171fca Andy McCall ↶ 101
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
b11e30f5 Andy McCall ↶ 102
        Ok(())
103
    }
104
e0918b88 Andy McCall 105
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
106
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
107
        Ok(())
108
    }
109
a67b97e9 Andy McCall ↶ 110
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
111
    /// a blob of the job's repository and answers with its ObjectRef.
112
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
113
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
89171fca Andy McCall ↶ 114
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
115
            self.agent
a67b97e9 Andy McCall ↶ 116
                .post(&url)
117
                .timeout(Duration::from_secs(600))
118
                .set("Authorization", &format!("Bearer {}", self.token))
89171fca Andy McCall ↶ 119
                .set("Content-Type", "application/octet-stream")
120
                .send_bytes(bytes)
121
                .map_err(Box::new)
122
        })?;
123
        let v: serde_json::Value = r.into_json()?;
124
        v["blob"].as_str().map(String::from).context("the hub named no blob")
a67b97e9 Andy McCall ↶ 125
    }
126
6d53fb7d Andy McCall ↶ 127
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
128
    /// the hub keeps it as a blob and answers with its ObjectRef. The
129
    /// report's manifest follows through [`Hub::artifact`].
130
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
131
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
89171fca Andy McCall ↶ 132
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
133
            self.agent
6d53fb7d Andy McCall ↶ 134
                .post(&url)
135
                .timeout(Duration::from_secs(600))
136
                .set("Authorization", &format!("Bearer {}", self.token))
137
                .set("Content-Type", "application/octet-stream")
89171fca Andy McCall ↶ 138
                .set("X-Flow-Report-Path", path)
139
                .send_bytes(bytes)
140
                .map_err(Box::new)
141
        })?;
142
        let v: serde_json::Value = r.into_json()?;
143
        v["blob"].as_str().map(String::from).context("the hub named no blob")
6d53fb7d Andy McCall ↶ 144
    }
145
3007d8f0 Andy McCall ↶ 146
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
147
    /// cannot be reached or fails (5xx), try again after `first_wait`,
148
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
149
    /// already, say) is final. A result lost here leaves the job running
150
    /// on the hub until its time limit.
151
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
152
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
153
        let deadline = std::time::Instant::now() + patience;
154
        let mut wait = first_wait;
155
        loop {
156
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
157
                Ok(_) => return Ok(()),
158
                Err(ureq::Error::Status(code, r)) if code < 500 => {
159
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
160
                }
161
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
162
                Err(e) => e.to_string(),
163
            };
164
            if std::time::Instant::now() + wait > deadline {
165
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
166
            }
167
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
168
            std::thread::sleep(wait);
169
            wait = (wait * 2).min(Duration::from_secs(60));
170
        }
171
    }
172
}
173
174
/// How long a runner keeps trying to report a job's end.
175
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
176
177
/// Where a job's result waits until the hub has it, so a runner that
178
/// restarts still reports it.
179
fn pending_path(home: &Home, job: i64) -> PathBuf {
180
    home.root.join("pending").join(format!("{job}.json"))
181
}
182
183
/// Report `f`, keeping it on disk until the hub has it.
184
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
185
    let path = pending_path(home, job);
186
    let kept = std::fs::create_dir_all(path.parent().unwrap())
187
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
188
        .is_ok();
189
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
190
        Ok(()) => {}
191
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
192
    }
193
    if kept {
194
        let _ = std::fs::remove_file(&path);
f98a8f78 Andy McCall ↶ 195
    }
3007d8f0 Andy McCall ↶ 196
}
197
198
/// Results a previous run of the runner could not report, to send again.
199
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
200
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
201
        return Vec::new();
202
    };
203
    dir.filter_map(|e| {
204
        let p = e.ok()?.path();
205
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
206
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
207
        Some((job, f))
208
    })
209
    .collect()
f98a8f78 Andy McCall ↶ 210
}
211
212
/// `flow runner register`: exchange a one-time registration token from
213
/// the hub for this runner's credential, binding the runner's signing key,
214
/// and keep hub, credential and name in runner.toml.
215
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
216
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
217
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
218
    }
219
    let key = home.runner_key(name)?;
220
    let hub = hub.trim_end_matches('/').to_string();
221
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
222
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
223
    let req = Register {
224
        token: token.trim().to_string(),
225
        name: name.to_string(),
226
        identity: hex::encode(key.identity.encode()),
2d025bbf Andy McCall ↶ 227
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
f98a8f78 Andy McCall ↶ 228
    };
229
    let url = format!("{hub}/api/v1/flow/runners/register");
230
    let got: Registered = match agent.post(&url).send_json(&req) {
231
        Ok(r) => r.into_json()?,
232
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
233
        Err(e) => bail!("{url}: {e}"),
234
    };
235
    let mut text = format!(
236
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
237
        got.credential, got.name
238
    );
239
    if let Some(ca) = &ca {
240
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
241
    }
242
    if !labels.is_empty() {
243
        text.push_str(&format!("labels = {labels:?}\n"));
244
    }
245
    let path = home.root.join(crate::home::RUNNER_TOML);
246
    {
247
        use std::io::Write;
248
        use std::os::unix::fs::OpenOptionsExt;
249
        let tmp = path.with_extension("toml.tmp");
250
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
251
        f.write_all(text.as_bytes())?;
252
        f.sync_all()?;
253
        std::fs::rename(&tmp, &path)?;
e0918b88 Andy McCall 254
    }
f98a8f78 Andy McCall ↶ 255
    Ok(got)
e0918b88 Andy McCall 256
}
1b2229da Andy McCall ↶ 257
258
/// Waits before trying a hub request again after a server error.
259
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
89171fca Andy McCall ↶ 260
261
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
262
/// all, longer than a hub takes to restart.
263
const PATIENT_DELAYS: [Duration; 15] = {
264
    let mut d = [Duration::from_secs(15); 15];
265
    d[0] = Duration::from_secs(1);
266
    d[1] = Duration::from_secs(2);
267
    d[2] = Duration::from_secs(4);
268
    d[3] = Duration::from_secs(8);
269
    d
270
};
271
272
/// Send a request until the hub answers, waiting `delays` in turn after a
273
/// server error (5xx: nothing was committed) and, when `connection`, after
274
/// the hub could not be reached at all. A refusal (4xx) is final, as a
275
/// [`Refused`] the caller can look at.
276
fn retrying(
277
    url: &str,
278
    delays: &[Duration],
279
    connection: bool,
280
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
281
) -> Result<ureq::Response> {
282
    let mut delays = delays.iter();
283
    loop {
284
        let why = match send().map_err(|e| *e) {
285
            Ok(r) => return Ok(r),
286
            Err(ureq::Error::Status(code, r)) if code < 500 => {
287
                let text = r.into_string().unwrap_or_default();
288
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
289
            }
290
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
291
            Err(e) if connection => e.to_string(),
292
            Err(e) => bail!("{url}: {e}"),
293
        };
294
        match delays.next() {
295
            Some(d) => std::thread::sleep(*d),
296
            None => bail!("{url}: {why}"),
297
        }
298
    }
299
}
e0918b88 Andy McCall 300
bb643c25 Andy McCall ↶ 301
/// What the runner may run at once (flow.toml `[runner]`): at most
302
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
303
#[derive(Debug, Default, PartialEq, Eq)]
304
pub struct Budget {
305
    pub max_jobs: usize,
306
    pub memory_mib: Option<u32>,
307
    pub cpus: Option<u32>,
308
}
309
310
/// What is in use now.
311
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
312
pub struct Used {
313
    pub jobs: usize,
314
    pub memory_mib: u32,
315
    pub cpus: u32,
316
}
317
318
impl Budget {
319
    pub fn of(cfg: &RunnerConfig) -> Budget {
320
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
321
    }
322
323
    /// The room to ask the hub for, or `None` when no job more can start.
324
    /// A lone job may use the whole budget even if it asks for more, so a
325
    /// large sandbox is never starved.
326
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
327
        if used.jobs >= self.max_jobs {
328
            return None;
329
        }
330
        if used.jobs == 0 {
331
            return Some((None, None));
332
        }
333
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
334
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
335
        if mem == Some(0) || cpus == Some(0) {
336
            return None;
337
        }
338
        Some((mem, cpus))
339
    }
340
}
341
c5b702e5 Andy McCall ↶ 342
/// The isolation tiers this machine can honour now (spec §11.1): the
ea56c56e Andy McCall ↶ 343
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
344
/// flow.toml pins Firecracker; the syscall tier when this binary may use
345
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
346
/// `Err` says why there are none; the reasons for missing tiers come back
347
/// with the ones there are.
348
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
349
    let mut have = Vec::new();
350
    let mut missing = Vec::new();
c5b702e5 Andy McCall ↶ 351
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
352
    match (kvm, home.config.microvm.is_some()) {
ea56c56e Andy McCall ↶ 353
        (Ok(_), true) => have.push("microvm".to_string()),
354
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
355
        (Err(e), _) => missing.push(format!(
356
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
c5b702e5 Andy McCall ↶ 357
        )),
358
    }
ea56c56e Andy McCall ↶ 359
    match crate::syscall::available() {
360
        Ok(()) => have.push("syscall".to_string()),
361
        Err(e) => missing.push(format!("syscall: {e}")),
362
    }
363
    if have.is_empty() {
364
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
365
    }
366
    Ok((have, missing))
b11e30f5 Andy McCall ↶ 367
}
368
4cf0c99a Andy McCall ↶ 369
/// Fetch `claim`'s image from the hub when this home lacks it
370
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
371
/// leaves it missing, and the preflight declines the job as before.
b14aeaae Andy McCall ↶ 372
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) {
4cf0c99a Andy McCall ↶ 373
    let hash = &claim.sandbox.rootfs;
374
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
375
        return;
376
    }
377
    let note = |text: String| {
378
        eprintln!("job {}: {text}", claim.job_id);
379
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
380
    };
381
    // One fetch of an image at a time: two jobs wanting it wait for one.
382
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
383
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
384
    if home.rootfs(hash).is_file() {
385
        return;
386
    }
387
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
388
        Ok(c) => c,
389
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
390
    };
b14aeaae Andy McCall ↶ 391
    let fetched = match swarm {
392
        Some(sw) => {
393
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
394
            sw.fetch(home, hash)
395
        }
396
        None => {
397
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
398
            client.fetch(home, hash)
399
        }
400
    };
401
    match fetched {
4cf0c99a Andy McCall ↶ 402
        Ok(Some(m)) => note(format!(
b14aeaae Andy McCall ↶ 403
            "image {}: fetched {} chunks, {}{} in {:.1} s",
4cf0c99a Andy McCall ↶ 404
            &hash[..12],
405
            m.chunks,
406
            crate::images::human(m.bytes),
b14aeaae Andy McCall ↶ 407
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
4cf0c99a Andy McCall ↶ 408
            m.elapsed.as_secs_f64()
409
        )),
410
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
411
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
412
    }
413
}
414
b11e30f5 Andy McCall ↶ 415
/// Why this runner cannot run `claim` now (spec §11.5), checked before
416
/// anything is fetched: the tier, the architecture, the image and the
417
/// memory. The hub chose it from what the runner last said, which may be
418
/// out of date.
419
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
420
    let sb = &claim.sandbox;
421
    match sb.runtime {
422
        flow_core::Runtime::Microvm => {
423
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
424
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
425
            }
426
            if home.config.microvm.is_none() {
427
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
428
            }
429
        }
430
        flow_core::Runtime::Syscall => {
431
            if let Err(e) = crate::syscall::available() {
432
                return Some(format!("the syscall tier cannot run here: {e}"));
433
            }
434
        }
435
        flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()),
436
    }
437
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
438
}
439
440
/// What stops `sb` running on this home's images, on a machine of
441
/// architecture `here` with `free_mib` of memory free.
442
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
443
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
444
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
445
    }
446
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
447
    let image = home.rootfs(&sb.rootfs);
448
    if !image.is_file() {
449
        return Some(format!("rootfs {short} is not on this runner"));
450
    }
451
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
452
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
453
    }
454
    if let Err(e) = home.verified(&image, &sb.rootfs) {
455
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
456
    }
457
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
458
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
459
    }
460
    None
461
}
462
463
/// `MemAvailable` from /proc/meminfo, in MiB.
464
fn available_memory_mib() -> Option<u64> {
465
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
466
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
467
    Some(kib / 1024)
c5b702e5 Andy McCall ↶ 468
}
469
e0918b88 Andy McCall 470
pub fn serve(home: &Home) -> Result<()> {
471
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
c5b702e5 Andy McCall ↶ 472
    // A runner offers only what it can run: a machine without KVM claims
473
    // nothing rather than taking MicroVM jobs and failing them (#36).
ea56c56e Andy McCall ↶ 474
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
475
    for why in &missing {
476
        eprintln!("flow runner: not offering {why}");
477
    }
e0918b88 Andy McCall 478
    let hub = Hub::new(&cfg)?;
479
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
bb643c25 Andy McCall ↶ 480
    let budget = Budget::of(&cfg);
89171fca Andy McCall ↶ 481
    drain_on_stop();
47c1d64d Andy McCall ↶ 482
    let key = home.runner_key(&name)?;
b14aeaae Andy McCall ↶ 483
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
484
    // peers still runs jobs, fetching images from the hub alone.
485
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
486
        eprintln!("flow runner: not joining the swarm: {e:#}");
487
        None
488
    });
47c1d64d Andy McCall ↶ 489
    eprintln!(
c5b702e5 Andy McCall ↶ 490
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
47c1d64d Andy McCall ↶ 491
        cfg.hub,
492
        budget.max_jobs,
c5b702e5 Andy McCall ↶ 493
        runtimes.join(", "),
47c1d64d Andy McCall ↶ 494
        key.identity.hash().to_hex()
495
    );
bb643c25 Andy McCall ↶ 496
    let used = std::sync::Mutex::new(Used::default());
e0918b88 Andy McCall 497
    let mut idle = Duration::from_secs(2);
3007d8f0 Andy McCall ↶ 498
    let pending = report_pending(home);
1daca0b6 Andy McCall ↶ 499
    // The jobs held now, told to the hub at each claim so it can end the
500
    // ones this runner lost: a job is held from its claim until its result
501
    // is reported.
502
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
503
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
3007d8f0 Andy McCall ↶ 504
    std::thread::scope(|scope| {
505
        for (job, f) in pending {
1daca0b6 Andy McCall ↶ 506
            let (hub, held) = (&hub, &held);
3007d8f0 Andy McCall ↶ 507
            scope.spawn(move || {
508
                eprintln!("job {job}: reporting the result kept from before the restart");
509
                report_finish(home, hub, job, &f);
1daca0b6 Andy McCall ↶ 510
                held.lock().unwrap().remove(&job);
3007d8f0 Andy McCall ↶ 511
            });
512
        }
62829d15 Andy McCall ↶ 513
        // The speed (spec §11.1): measured now, and again every 15 minutes
514
        // while no job runs, so a job never shares the CPU with it.
515
        let measure = || {
516
            let (speed, ms) = crate::facts::bench();
517
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
518
            speed
519
        };
520
        let mut speed = measure();
521
        let mut benched = Instant::now();
89171fca Andy McCall ↶ 522
        let mut said_draining = false;
3007d8f0 Andy McCall ↶ 523
        loop {
89171fca Andy McCall ↶ 524
            // Draining (oxygenflow#50): told to stop, claim nothing more and
525
            // stop once every job held is done and reported.
526
            if stopping() {
527
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
528
                if jobs == 0 && holding == 0 {
529
                    eprintln!("flow runner {name}: drained, stopping");
530
                    break;
531
                }
532
                if !said_draining {
533
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
534
                    said_draining = true;
535
                }
536
                std::thread::sleep(Duration::from_millis(500));
537
                continue;
538
            }
62829d15 Andy McCall ↶ 539
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
540
                speed = measure();
541
                benched = Instant::now();
542
            }
3007d8f0 Andy McCall ↶ 543
            let room = budget.room(*used.lock().unwrap());
544
            let Some((free_memory_mib, free_cpus)) = room else {
545
                std::thread::sleep(Duration::from_millis(500));
546
                continue;
547
            };
548
            let req = ClaimRequest {
549
                runner: name.clone(),
c5b702e5 Andy McCall ↶ 550
                runtimes: runtimes.clone(),
3007d8f0 Andy McCall ↶ 551
                free_memory_mib,
552
                free_cpus,
553
                info: Some(RunnerInfo {
554
                    version: env!("OXY_VERSION_LONG").into(),
555
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
556
                }),
8c0a4e3a Andy McCall ↶ 557
                // Read each time: an image imported while the runner runs counts.
558
                images: Some(home.images()),
1abadd4a Andy McCall ↶ 559
                image_archs: Some(home.image_archs()),
1daca0b6 Andy McCall ↶ 560
                holding: Some(held.lock().unwrap().iter().copied().collect()),
4cf0c99a Andy McCall ↶ 561
                fetches_images: true,
62829d15 Andy McCall ↶ 562
                facts: Some(crate::facts::now(home, Some(speed))),
b14aeaae Andy McCall ↶ 563
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
82cf6e91 Andy McCall ↶ 564
                caches: Some(crate::cache::Pool::new(&home.root, cfg.cache_gb).slots()),
3007d8f0 Andy McCall ↶ 565
            };
566
            match hub.claim(&req) {
567
                Ok(Some(claim)) => {
568
                    idle = Duration::from_secs(2);
569
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
570
                    {
571
                        let mut u = used.lock().unwrap();
572
                        u.jobs += 1;
573
                        u.memory_mib += mem;
574
                        u.cpus += cpus;
bb643c25 Andy McCall ↶ 575
                    }
1daca0b6 Andy McCall ↶ 576
                    held.lock().unwrap().insert(claim.job_id);
b14aeaae Andy McCall ↶ 577
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
3007d8f0 Andy McCall ↶ 578
                    scope.spawn(move || {
579
                        let id = claim.job_id;
b14aeaae Andy McCall ↶ 580
                        fetch_image(home, hub, cfg, swarm, &claim);
b11e30f5 Andy McCall ↶ 581
                        if let Some(why) = preflight(home, &claim) {
582
                            eprintln!("job {id}: declined: {why}");
583
                            if let Err(e) = hub.decline(id, &why) {
584
                                eprintln!("job {id}: decline: {e:#}");
585
                            }
586
                            held.lock().unwrap().remove(&id);
587
                            let mut u = used.lock().unwrap();
588
                            u.jobs -= 1;
589
                            u.memory_mib -= mem;
590
                            u.cpus -= cpus;
591
                            return;
592
                        }
3007d8f0 Andy McCall ↶ 593
                        eprintln!(
594
                            "job {id}: {} {} on {}",
595
                            claim.repo,
596
                            claim.job.name,
597
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
598
                        );
599
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
600
                            exit_code: None,
601
                            duration_ms: 0,
602
                            receipt: None,
603
                            signed_receipt: None,
604
                            error: Some(format!("{e:#}")),
8f211e20 Andy McCall ↶ 605
                            peak_disk_mib: None,
606
                            peak_memory_mib: None,
3007d8f0 Andy McCall ↶ 607
                        });
608
                        eprintln!(
609
                            "job {id}: exit {:?}{}",
610
                            finish.exit_code,
611
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
612
                        );
280ef951 Andy McCall ↶ 613
                        if finish.error.as_deref() != Some(CANCELLED) {
614
                            report_finish(home, hub, id, &finish);
615
                        }
1daca0b6 Andy McCall ↶ 616
                        held.lock().unwrap().remove(&id);
3007d8f0 Andy McCall ↶ 617
                        let mut u = used.lock().unwrap();
618
                        u.jobs -= 1;
619
                        u.memory_mib -= mem;
620
                        u.cpus -= cpus;
621
                    });
622
                }
623
                Ok(None) => std::thread::sleep(idle),
624
                Err(e) => {
625
                    eprintln!("claim: {e:#}");
626
                    std::thread::sleep(idle);
627
                    idle = (idle * 2).min(Duration::from_secs(60));
628
                }
e0918b88 Andy McCall 629
            }
630
        }
89171fca Andy McCall ↶ 631
        Ok(())
bb643c25 Andy McCall ↶ 632
    })
89171fca Andy McCall ↶ 633
}
634
635
/// How many times the runner was told to stop (SIGTERM or SIGINT).
636
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
637
638
extern "C" fn on_stop(_: libc::c_int) {
639
    // A second stop means now: only async-signal-safe calls here.
640
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
641
        unsafe { libc::_exit(143) };
642
    }
643
}
644
645
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
646
/// so restarting a runner never kills the job it runs: the unit's
647
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
648
fn drain_on_stop() {
649
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
650
    unsafe {
651
        libc::signal(libc::SIGTERM, handler);
652
        libc::signal(libc::SIGINT, handler);
653
    }
e0918b88 Andy McCall 654
}
655
89171fca Andy McCall ↶ 656
fn stopping() -> bool {
657
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
658
}
659
280ef951 Andy McCall ↶ 660
/// Batches records to the hub every second (or every 200 lines). A quiet
661
/// job's sink still asks the hub every few seconds whether the job runs,
662
/// with an empty batch; once the hub answers 409 the job is not wanted.
e0918b88 Andy McCall 663
struct LogSink<'a> {
664
    hub: &'a Hub,
665
    job: i64,
666
    buf: Vec<Record>,
667
    last: Instant,
280ef951 Andy McCall ↶ 668
    gone: bool,
669
}
89171fca Andy McCall ↶ 670
671
/// The most log lines sent at once, as a busy job's sink flushes them.
672
const BATCH: usize = 200;
280ef951 Andy McCall ↶ 673
89171fca Andy McCall ↶ 674
/// The most log lines kept while the hub cannot be reached.
675
const MAX_UNSENT: usize = 50_000;
676
280ef951 Andy McCall ↶ 677
/// How often a quiet job asks whether it was cancelled.
678
const HEARTBEAT: Duration = Duration::from_secs(5);
679
680
impl microvm::Sink for LogSink<'_> {
681
    fn record(&mut self, r: Record) {
682
        self.push(r)
683
    }
684
685
    fn cancelled(&mut self) -> bool {
686
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
687
            self.send();
688
        }
689
        self.gone
690
    }
e0918b88 Andy McCall 691
}
692
693
impl LogSink<'_> {
694
    fn push(&mut self, mut r: Record) {
280ef951 Andy McCall ↶ 695
        if self.gone {
696
            return;
697
        }
e0918b88 Andy McCall 698
        if r.text.len() > MAX_RECORD_TEXT {
699
            let mut cut = MAX_RECORD_TEXT;
700
            while !r.text.is_char_boundary(cut) {
701
                cut -= 1;
702
            }
703
            r.text.truncate(cut);
704
        }
705
        self.buf.push(r);
89171fca Andy McCall ↶ 706
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
e0918b88 Andy McCall 707
            self.flush();
708
        }
709
    }
710
711
    fn flush(&mut self) {
712
        if !self.buf.is_empty() {
280ef951 Andy McCall ↶ 713
            self.send();
714
        }
715
        self.last = Instant::now();
716
    }
717
89171fca Andy McCall ↶ 718
    /// Send what is buffered, even nothing, in batches of at most
719
    /// `BATCH` lines.
280ef951 Andy McCall ↶ 720
    fn send(&mut self) {
89171fca Andy McCall ↶ 721
        loop {
722
            if self.gone {
723
                return;
724
            }
725
            let n = self.buf.len().min(BATCH);
726
            let batch: Vec<Record> = self.buf.drain(..n).collect();
727
            match self.hub.log(self.job, batch.clone()) {
728
                Ok(()) => {}
729
                Err(e) if job_gone(&e) => self.gone = true,
730
                Err(e) => {
731
                    // The hub is away (a restart, say): keep the lines for
732
                    // the next batch, up to a bound, so the log loses none
733
                    // (oxygenflow#49).
734
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
735
                    let mut kept = batch;
736
                    kept.append(&mut self.buf);
737
                    let over = kept.len().saturating_sub(MAX_UNSENT);
738
                    if over > 0 {
739
                        kept.drain(..over);
740
                        kept.insert(
741
                            0,
742
                            Record {
743
                                t: 0,
744
                                stream: "flow".into(),
745
                                text: format!("({over} log lines lost while the hub could not be reached)"),
746
                            },
747
                        );
748
                    }
749
                    self.buf = kept;
750
                    break;
751
                }
752
            }
753
            if self.buf.is_empty() {
754
                break;
755
            }
e0918b88 Andy McCall 756
        }
757
        self.last = Instant::now();
758
    }
759
}
bb643c25 Andy McCall ↶ 760
ed4aef8b Andy McCall ↶ 761
/// Held while a job takes its cache disks and images its workspace (see
762
/// `run_claim`): one job at a time on the runner's disk and cache pool.
bb643c25 Andy McCall ↶ 763
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
e0918b88 Andy McCall 764
ed4aef8b Andy McCall ↶ 765
/// Held while the first clone of any repository passes its token through
766
/// the process environment.
767
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
768
769
/// One lock per cached repository: fetches into it, and exports from it,
770
/// never overlap, while jobs of other repositories go on. A slow repository
771
/// (one kept in a bucket, say) once held every job on the runner waiting
772
/// behind a single global lock.
773
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
774
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
775
        std::sync::OnceLock::new();
776
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
777
    locks.entry(name.to_string()).or_default().clone()
778
}
779
780
/// Seconds, to one decimal place, for the job's log.
781
fn secs(d: std::time::Duration) -> String {
782
    format!("{:.1} s", d.as_secs_f64())
783
}
784
47c1d64d Andy McCall ↶ 785
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
e0918b88 Andy McCall 786
    let started = Instant::now();
280ef951 Andy McCall ↶ 787
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
e0918b88 Andy McCall 788
    let note =
789
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
790
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
791
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
ed4aef8b Andy McCall ↶ 792
    // Preparing a workspace: fetches into a cached repository, and exports
793
    // from it, are one job at a time per repository (`repo_lock`); taking
794
    // cache disks and imaging the workspace are one job at a time on the
795
    // runner (`PREPARE`); a first clone, whose token goes through the process
796
    // environment, one at a time (`CLONE`).
797
    let main_lock = repo_lock(&claim.repo);
798
    let asked = Instant::now();
799
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
800
    if asked.elapsed().as_secs() >= 2 {
801
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
802
    }
803
    let fetching = Instant::now();
e0918b88 Andy McCall 804
    let repo = cached_repo(home, cfg, claim)?;
ed4aef8b Andy McCall ↶ 805
    let fetched = fetching.elapsed();
806
    let mut preparing = None;
e0918b88 Andy McCall 807
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
808
    let _ = std::fs::remove_dir_all(&scratch);
809
    std::fs::create_dir_all(scratch.join("tree"))?;
810
    let result = (|| -> Result<Finish> {
ed4aef8b Andy McCall ↶ 811
        let exporting = Instant::now();
c23d5191 Andy McCall ↶ 812
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
ed4aef8b Andy McCall ↶ 813
        main_guard.take();
814
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
c23d5191 Andy McCall ↶ 815
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
816
        // workspace and of the scope hash.
817
        for sub in &claim.subrepos {
818
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
819
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
820
            }
821
            note(
822
                &mut sink,
823
                format!(
824
                    "fetching subrepo {} at {} for {}",
825
                    sub.repo,
826
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
827
                    sub.path
828
                ),
829
            );
ed4aef8b Andy McCall ↶ 830
            let sub_lock = repo_lock(&sub.repo);
831
            let asked = Instant::now();
832
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
833
            if asked.elapsed().as_secs() >= 2 {
834
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
835
            }
c23d5191 Andy McCall ↶ 836
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
837
            let sub_heads: Vec<ChangeHash> =
838
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
839
            let dir = scratch.join("tree").join(&sub.path);
840
            std::fs::create_dir_all(&dir)?;
841
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
ed4aef8b Andy McCall ↶ 842
            drop(sub_guard);
c23d5191 Andy McCall ↶ 843
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
844
        }
e0918b88 Andy McCall 845
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
ea56c56e Andy McCall ↶ 846
        // The syscall tier runs on the tree itself; a microVM gets an image.
847
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
82cf6e91 Andy McCall ↶ 848
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
849
        // Disk mounts that name a cache start from a kept disk (#28); the
850
        // microVM takes a disk already in place as it is. Taken before the
851
        // workspace is imaged: the sources the disk was built from give
852
        // unchanged files their old times back (#52).
ed4aef8b Andy McCall ↶ 853
        let asked = Instant::now();
854
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
855
        if asked.elapsed().as_secs() >= 2 {
856
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
857
        }
82cf6e91 Andy McCall ↶ 858
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
859
        let cache_ctx =
860
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs };
861
        let mut cached: Vec<(usize, String, String)> = Vec::new();
862
        let mut built_from: Option<crate::cache::Sources> = None;
863
        if !syscall_tier {
864
            for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
865
                let Some(name) = &m.cache else { continue };
866
                let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
867
                let how = match pool.take(&cache_ctx, m, &key, &scratch.join(format!("mount-{i}.ext4"))) {
868
                    Some(t) => {
869
                        if built_from.is_none() {
870
                            built_from = t.sources;
871
                        }
872
                        t.how
873
                    }
874
                    None => format!("cache {name}: none kept yet, an empty disk"),
875
                };
876
                note(&mut sink, format!("{} ({})", how, m.target));
877
                cached.push((i, key, how));
878
            }
879
        }
880
        let sources = if cached.is_empty() {
881
            crate::cache::Sources::new()
882
        } else {
883
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
884
            if built_from.is_some() {
885
                note(&mut sink, format!("cache: {restored} of {} files unchanged since the cached build, their times kept", sources.len()));
886
            }
887
            sources
888
        };
ea56c56e Andy McCall ↶ 889
        if !syscall_tier {
890
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
891
            std::fs::remove_dir_all(scratch.join("tree"))?;
892
        }
bb643c25 Andy McCall ↶ 893
        preparing.take();
ea56c56e Andy McCall ↶ 894
        // Cancelled while it was being prepared: no sandbox.
280ef951 Andy McCall ↶ 895
        if microvm::Sink::cancelled(&mut sink) {
896
            return Ok(cancelled());
897
        }
e0918b88 Andy McCall 898
        note(
899
            &mut sink,
900
            format!(
ea56c56e Andy McCall ↶ 901
                "{}: {} {}, {} MiB, rootfs {}",
902
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
e0918b88 Andy McCall 903
                claim.sandbox.cpus,
ea56c56e Andy McCall ↶ 904
                if syscall_tier { "CPU" } else { "vCPU" },
e0918b88 Andy McCall 905
                claim.sandbox.memory_mib,
906
                &claim.sandbox.rootfs[..12]
907
            ),
908
        );
ea56c56e Andy McCall ↶ 909
        let timeout = Duration::from_secs(claim.timeout_s);
d60aac6c Andy McCall ↶ 910
        let mut outcome = if syscall_tier {
ea56c56e Andy McCall ↶ 911
            let group = format!("job-{}", claim.job_id);
912
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
913
        } else {
914
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
915
        };
280ef951 Andy McCall ↶ 916
        if outcome.cancelled {
917
            return Ok(cancelled());
d60aac6c Andy McCall ↶ 918
        }
919
        // The receipt says what a cached disk started from.
920
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
921
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
922
        for (i, key, how) in &cached {
923
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
280ef951 Andy McCall ↶ 924
        }
a67b97e9 Andy McCall ↶ 925
        // A passing job's artifacts, read from its disks and uploaded; a
9a6fac2b Andy McCall ↶ 926
        // declared artifact that cannot be kept fails the job. A failing
927
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
928
        // its receipt, and one it cannot keep is only noted.
a67b97e9 Andy McCall ↶ 929
        let mut outputs = vec![];
930
        let mut output_error = None;
9a6fac2b Andy McCall ↶ 931
        let passed = outcome.exit_code == Some(0);
932
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
933
        for o in wanted {
6d53fb7d Andy McCall ↶ 934
            if o.kind == flow_core::OutputKind::Report {
935
                let read = if syscall_tier {
936
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
937
                    crate::outputs::read_report_dir(&dir, &inner)
938
                } else {
939
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
940
                    crate::outputs::read_report(&image, &inner, &scratch)
941
                };
942
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
943
                match kept {
944
                    Ok((blob, files, bytes)) => {
945
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
946
                        let hex = blob.to_hex();
947
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
948
                        if passed {
949
                            outputs.push(blob.0);
950
                        }
951
                    }
952
                    Err(e) if passed => {
953
                        output_error = Some(format!("report {}: {e:#}", o.name));
954
                        break;
955
                    }
956
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
957
                }
958
                continue;
959
            }
9a6fac2b Andy McCall ↶ 960
            let read = if syscall_tier {
961
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
962
                crate::outputs::read_dir_file(&dir, &inner)
963
            } else {
964
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
965
                crate::outputs::read(&image, &inner, &scratch)
966
            };
967
            let kept = read.and_then(|bytes| {
968
                let blob = Repo::blob_ref(&bytes);
969
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
970
                if named != blob.to_hex() {
971
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
972
                }
973
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
974
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
975
                Ok(blob.0)
976
            });
977
            match kept {
978
                Ok(b) if passed => outputs.push(b),
979
                Ok(_) => {}
980
                Err(e) if passed => {
981
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
982
                    break;
a67b97e9 Andy McCall ↶ 983
                }
9a6fac2b Andy McCall ↶ 984
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
a67b97e9 Andy McCall ↶ 985
            }
986
        }
987
        if let Some(e) = &output_error {
988
            note(&mut sink, e.clone());
989
        }
990
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
e0918b88 Andy McCall 991
            job_name: claim.job.name.clone(),
992
            pipeline_hash: hex32(&claim.pipeline_hash),
993
            scope_tree_hash: scope_hash,
994
            inputs: vec![],
995
            environment: outcome.environment.hash(),
a67b97e9 Andy McCall ↶ 996
            outputs,
e0918b88 Andy McCall 997
            exit_code: code as u32,
998
            duration_ms: outcome.duration_ms,
999
        });
47c1d64d Andy McCall ↶ 1000
        let signed_receipt = match &receipt {
1001
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
1002
            None => None,
1003
        };
d60aac6c Andy McCall ↶ 1004
        // Measured before a cached disk leaves the scratch directory.
1005
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1006
        // A passing job's cached disks are kept; a failing job's are not,
1007
        // so nothing it wrote reaches a later job.
1008
        if passed && output_error.is_none() {
1009
            for (i, key, _) in &cached {
82cf6e91 Andy McCall ↶ 1010
                if let Err(e) = pool.keep(&cache_ctx, disk_mounts[*i], key, &scratch.join(format!("mount-{i}.ext4")), &sources) {
d60aac6c Andy McCall ↶ 1011
                    note(&mut sink, format!("cache: not kept: {e:#}"));
1012
                }
1013
            }
1014
        }
e0918b88 Andy McCall 1015
        Ok(Finish {
1016
            exit_code: outcome.exit_code,
1017
            duration_ms: started.elapsed().as_millis() as u64,
1018
            receipt,
47c1d64d Andy McCall ↶ 1019
            signed_receipt,
a67b97e9 Andy McCall ↶ 1020
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
8f211e20 Andy McCall ↶ 1021
            // What the job took (oxygenflow#41): its disks are sparse files
1022
            // in the scratch directory, so their blocks now are their peak.
d60aac6c Andy McCall ↶ 1023
            peak_disk_mib,
8f211e20 Andy McCall ↶ 1024
            peak_memory_mib: outcome.peak_memory_mib,
e0918b88 Andy McCall 1025
        })
1026
    })();
ea56c56e Andy McCall ↶ 1027
    let _ = workspace::remove_tree(&scratch);
e0918b88 Andy McCall 1028
    sink.flush();
1029
    result
47c1d64d Andy McCall ↶ 1030
}
1031
1032
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
1033
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
1034
/// the claim, so the runner vouches only for what it fetched.
6d53fb7d Andy McCall ↶ 1035
/// Upload a report's files, then its manifest under the report's name, and
1036
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
1037
/// the manifest's blob, the file count and their total size.
1038
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
1039
    let mut listed = Vec::with_capacity(files.len());
1040
    let mut total = 0u64;
1041
    for (path, bytes) in files {
1042
        let blob = Repo::blob_ref(bytes).to_hex();
1043
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
1044
        if named != blob {
1045
            bail!("the hub kept {path} as {named}, not {blob}");
1046
        }
1047
        total += bytes.len() as u64;
1048
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
1049
    }
1050
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
1051
    let blob = Repo::blob_ref(&manifest);
1052
    let named = hub.artifact(job, name, &manifest)?;
1053
    if named != blob.to_hex() {
1054
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
1055
    }
1056
    Ok((blob, files.len(), total))
1057
}
1058
47c1d64d Andy McCall ↶ 1059
fn sign_receipt(
1060
    repo: &Repo,
1061
    heads: &[ChangeHash],
1062
    cfg: &RunnerConfig,
1063
    key: &RunnerKey,
1064
    claim: &Claim,
1065
    receipt: &Receipt,
1066
) -> Result<oxy_core::receipt::SignedReceipt> {
1067
    let state = state_of(heads);
1068
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1069
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
1070
}
1071
1072
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
1073
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1074
    use oxy_core::hash::{hash_object, ObjectType};
1075
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
e0918b88 Andy McCall 1076
}
1077
1078
fn hex32(s: &str) -> [u8; 32] {
1079
    let mut out = [0; 32];
1080
    if let Ok(v) = hex::decode(s) {
1081
        if v.len() == 32 {
1082
            out.copy_from_slice(&v);
1083
        }
1084
    }
1085
    out
1086
}
1087
f53e6d47 Andy McCall ↶ 1088
/// Whether a network error is worth another try: DNS, a refused or reset
1089
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
1090
/// hub restarts (oxygenhub#111).
1091
fn transient(msg: &str) -> bool {
1092
    let m = msg.to_lowercase();
1093
    [
1094
        "dns failed",
1095
        "name resolution",
1096
        "connection refused",
1097
        "connection reset",
1098
        "timed out",
1099
        "timeout",
1100
        "http 502",
1101
        "http 503",
1102
        "http 504",
1103
        "broken pipe",
1104
        "unexpected eof",
1105
    ]
1106
    .iter()
1107
    .any(|k| m.contains(k))
1108
}
1109
1110
/// Run `f` again after a transient network error, waiting `waits` between
1111
/// tries (about a minute in all), and give up on any other error at once.
1112
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
1113
    let mut tries = waits.iter();
1114
    loop {
1115
        match f() {
1116
            Ok(v) => return Ok(v),
1117
            Err(e) => match tries.next() {
1118
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
1119
                _ => return Err(e),
1120
            },
1121
        }
1122
    }
1123
}
1124
1125
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
1126
const FETCH_WAITS: [Duration; 5] =
1127
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
1128
e0918b88 Andy McCall 1129
/// The runner's copy of a repository, fetched up to date with the job's token.
1130
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
c23d5191 Andy McCall ↶ 1131
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
1132
}
1133
1134
/// The runner's copy of `repo`, cloned or fetched with `token`.
1135
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
1136
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
e0918b88 Andy McCall 1137
    let set_remote = |repo: &mut Repo| -> Result<()> {
c23d5191 Andy McCall ↶ 1138
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
1139
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
e0918b88 Andy McCall 1140
        if let Some(ca) = &cfg.ca {
1141
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
1142
        }
1143
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
1144
    };
1145
    if dir.join(".oxy").is_dir() {
1146
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
1147
        set_remote(&mut repo)?;
f53e6d47 Andy McCall ↶ 1148
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
e0918b88 Andy McCall 1149
        return Ok(repo);
1150
    }
ed4aef8b Andy McCall ↶ 1151
    // First job for this repository: clone with the job's token, which goes
1152
    // through the process environment, so one clone at a time.
1153
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
c23d5191 Andy McCall ↶ 1154
    std::env::set_var("OXY_TOKEN", token);
e0918b88 Andy McCall 1155
    if let Some(ca) = &cfg.ca {
1156
        std::env::set_var("OXY_CA", ca);
1157
    }
f53e6d47 Andy McCall ↶ 1158
    let cloned = with_retries(&FETCH_WAITS, || {
1159
        let _ = std::fs::remove_dir_all(&dir);
1160
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
1161
    });
e0918b88 Andy McCall 1162
    std::env::remove_var("OXY_TOKEN");
1163
    std::env::remove_var("OXY_CA");
1164
    let mut repo = cloned.map_err(|e| {
1165
        let _ = std::fs::remove_dir_all(&dir);
c23d5191 Andy McCall ↶ 1166
        anyhow::anyhow!("clone {}: {e}", clone_url)
e0918b88 Andy McCall 1167
    })?;
1168
    set_remote(&mut repo)?;
1169
    Ok(repo)
bb643c25 Andy McCall ↶ 1170
}
1171
1172
#[cfg(test)]
1173
mod tests {
1174
    use super::*;
b11e30f5 Andy McCall ↶ 1175
1176
    #[test]
1177
    fn a_runner_names_what_stops_a_sandbox() {
1178
        let dir = tempfile::tempdir().unwrap();
1179
        let home = Home::locate(Some(dir.path())).unwrap();
1180
        let bytes = b"not really ext4";
1181
        let hash = blake3::hash(bytes).to_hex().to_string();
1182
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
1183
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
1184
        }))
1185
        .unwrap();
1186
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
1187
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
1188
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
1189
        sb.rootfs = hash.clone();
1190
        assert_eq!(problem(&sb, Some(4096)), None);
1191
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
1192
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
1193
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
1194
        sb.arch = Some("aarch64".into());
1195
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
1196
        sb.arch = None;
1197
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
1198
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
1199
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
1200
    }
f53e6d47 Andy McCall ↶ 1201
1202
    #[test]
1203
    fn transient_network_errors_are_tried_again_and_others_are_not() {
1204
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
1205
        assert!(transient("HTTP 503: the hub is restarting"));
1206
        assert!(!transient("HTTP 404: no such repository"));
1207
        let mut n = 0;
1208
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
1209
            n += 1;
1210
            if n < 3 {
1211
                Err(anyhow::anyhow!("connection refused"))
1212
            } else {
1213
                Ok(7)
1214
            }
1215
        });
1216
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
1217
        let mut m = 0;
1218
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
1219
            m += 1;
1220
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
1221
        });
1222
        assert!(got.is_err());
1223
        assert_eq!(m, 1, "a refusal is not tried again");
1224
        let mut k = 0;
1225
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
1226
            k += 1;
1227
            Err(anyhow::anyhow!("timed out"))
1228
        });
1229
        assert!(got.is_err());
1230
        assert_eq!(k, 3, "gives up after the last wait");
1231
    }
1b2229da Andy McCall ↶ 1232
1233
    /// A hub that answers each request with the next of `codes`, counting them.
1234
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
89171fca Andy McCall ↶ 1235
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
1236
    }
1237
1238
    /// A hub at `listener` answering each request with the next of `codes`.
1239
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1b2229da Andy McCall ↶ 1240
        use std::io::{Read, Write};
1241
        let addr = listener.local_addr().unwrap();
1242
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1243
        let count = seen.clone();
1244
        std::thread::spawn(move || {
1245
            for code in codes {
1246
                let (mut s, _) = listener.accept().unwrap();
1247
                let mut buf = [0u8; 8192];
1248
                let _ = s.read(&mut buf);
1249
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1250
                let reason = if code == 204 { "No Content" } else { "Error" };
1251
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
1252
            }
1253
        });
1254
        (format!("http://{addr}"), seen)
f98a8f78 Andy McCall ↶ 1255
    }
1256
1257
    /// A hub that answers one request with `body` (status 200) and hands
1258
    /// back what it was sent.
1259
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
1260
        use std::io::{Read, Write};
1261
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1262
        let addr = listener.local_addr().unwrap();
1263
        let (tx, rx) = std::sync::mpsc::channel();
1264
        std::thread::spawn(move || {
1265
            let (mut s, _) = listener.accept().unwrap();
1266
            let mut req = Vec::new();
1267
            let mut buf = [0u8; 65536];
1268
            loop {
1269
                let n = s.read(&mut buf).unwrap_or(0);
1270
                if n == 0 {
1271
                    break;
1272
                }
1273
                req.extend_from_slice(&buf[..n]);
1274
                let text = String::from_utf8_lossy(&req);
1275
                if let Some(end) = text.find("\r\n\r\n") {
1276
                    let len = text[..end]
1277
                        .lines()
1278
                        .find_map(|l| {
1279
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1280
                        })
1281
                        .unwrap_or(0);
1282
                    if req.len() >= end + 4 + len {
1283
                        break;
1284
                    }
1285
                }
1286
            }
1287
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
1288
            let _ = write!(
1289
                s,
1290
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1291
                body.len()
1292
            );
1293
        });
1294
        (format!("http://{addr}"), rx)
1295
    }
1296
1297
    #[test]
1298
    fn registering_binds_the_key_and_keeps_the_credential_private() {
1299
        use std::os::unix::fs::PermissionsExt;
1300
        let dir = tempfile::tempdir().unwrap();
1301
        let home = Home::locate(Some(dir.path())).unwrap();
1302
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
1303
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
1304
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
1305
        let req = sent.recv().unwrap();
1306
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
1307
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
1308
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
1309
        let key = home.runner_key("plex").unwrap();
1310
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
1311
        let path = dir.path().join(crate::home::RUNNER_TOML);
1312
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1313
        // The runner then starts from runner.toml.
1314
        let again = Home::locate(Some(dir.path())).unwrap();
1315
        let cfg = again.config.runner.unwrap();
1316
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
1317
        assert_eq!(cfg.labels, ["gpu"]);
1b2229da Andy McCall ↶ 1318
    }
1319
1320
    fn hub_at(base: String) -> Hub {
1321
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
3007d8f0 Andy McCall ↶ 1322
    }
1323
1324
    /// oxygenhub#71: a job's result is offered until the hub takes it,
1325
    /// through server errors, and a refusal ends the trying at once.
1326
    #[test]
1327
    fn a_result_is_reported_until_the_hub_takes_it() {
8f211e20 Andy McCall ↶ 1328
        let f = Finish {
1329
            exit_code: Some(0),
1330
            duration_ms: 1,
1331
            receipt: None,
1332
            signed_receipt: None,
1333
            error: None,
1334
            peak_disk_mib: None,
1335
            peak_memory_mib: None,
1336
        };
3007d8f0 Andy McCall ↶ 1337
        let quick = Duration::from_millis(10);
1338
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
1339
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
1340
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
1341
        let (base, seen) = fake_hub(vec![409]);
1342
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
1343
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1b2229da Andy McCall ↶ 1344
    }
1345
1346
    #[test]
1347
    fn server_errors_are_tried_again_and_client_errors_are_not() {
1348
        let quick = [Duration::from_millis(10); 3];
1349
        let (base, seen) = fake_hub(vec![500, 503, 204]);
1350
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
1351
        assert!(r.is_none());
1352
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
1353
1354
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
1355
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
1356
        assert!(e.contains("HTTP 500"), "{e}");
1357
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
1358
1359
        let (base, seen) = fake_hub(vec![401]);
1360
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
1361
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
280ef951 Andy McCall ↶ 1362
    }
1363
1364
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
1365
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
1366
    #[test]
1367
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
1368
        use microvm::Sink;
1369
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1370
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
1371
        let (base, seen) = fake_hub(vec![204]);
1372
        let hub = hub_at(base);
1373
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1374
        assert!(!sink.cancelled());
1375
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
1376
        assert_eq!(n(&seen), 1);
1377
1378
        let (base, seen) = fake_hub(vec![409]);
1379
        let hub = hub_at(base);
1380
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1381
        assert!(sink.cancelled());
1382
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
1383
        sink.flush();
1384
        assert!(sink.cancelled());
1385
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
89171fca Andy McCall ↶ 1386
    }
1387
1388
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
1389
    /// it) costs a job's uploads a wait, not the upload, and costs its log
1390
    /// no lines.
1391
    #[test]
1392
    fn a_job_outlasts_a_hub_restart() {
1393
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1394
        // A port nobody listens on yet: the hub is restarting.
1395
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1396
        let base = format!("http://127.0.0.1:{port}");
1397
        let hub = hub_at(base.clone());
1398
1399
        // Log lines sent while it is away are kept, and go when it is back.
1400
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1401
        for i in 0..450 {
1402
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
1403
        }
1404
        sink.send();
1405
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
1406
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
1407
        sink.send();
1408
        assert!(sink.buf.is_empty() && !sink.gone);
1409
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
1410
1411
        // An upload waits for the hub to come back, through refused
1412
        // connections and server errors alike.
1413
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1414
        let url = format!("http://127.0.0.1:{port}/x");
1415
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
1416
        let later = std::thread::spawn(move || {
1417
            std::thread::sleep(Duration::from_millis(150));
1418
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
1419
        });
1420
        let quick = [Duration::from_millis(50); 20];
1421
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
1422
        assert_eq!(r.status(), 204);
1423
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
1424
        // Without `connection`, a hub that cannot be reached ends it at once.
1425
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1426
        let url = format!("http://127.0.0.1:{port}/x");
1427
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
1428
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
1429
    }
1430
1431
    /// oxygenflow#50: the first stop drains (the second, not called here,
1432
    /// exits at once).
1433
    #[test]
1434
    fn a_stop_drains_the_runner() {
1435
        assert!(!stopping());
1436
        on_stop(libc::SIGTERM);
1437
        assert!(stopping(), "told to stop, it claims nothing more");
1438
    }
1439
1440
    /// Lines kept for a hub that stays away are bounded, saying how many went.
1441
    #[test]
1442
    fn unsent_log_lines_are_bounded() {
1443
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1444
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
1445
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1446
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
1447
        sink.send();
1448
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
1449
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
1450
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
1b2229da Andy McCall ↶ 1451
    }
bb643c25 Andy McCall ↶ 1452
1453
    #[test]
1454
    fn the_budget_admits_jobs_that_fit() {
1455
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1456
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1457
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1458
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1459
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1460
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1461
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1462
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1463
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1464
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1465
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1466
    }
e0918b88 Andy McCall 1467
}