OXYGENHUB
◆ v0.5.0 oxygenflow / crates / flow / src / runner.rs
109.6 KiB · Rust · last changed A job's finish report says where its time went: image, prepare, run, finish (refs #35) 8 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::{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 {
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
        phases: Default::default(),
51
    }
52
}
53
54
pub struct Hub {
55
    base: String,
56
    token: String,
57
    agent: ureq::Agent,
58
}
59
60
impl Hub {
61
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
62
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
63
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
64
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
65
    }
66
67
    /// POST to the hub. A server error (5xx) means nothing was committed,
68
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
69
    /// busy hub costs a job a few seconds, not its log lines or its claim.
70
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
71
        self.post_with(path, body, &RETRY_DELAYS)
72
    }
73
74
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
75
        let url = format!("{}/api/v1{path}", self.base);
76
        let r = retrying(&url, delays, false, || {
77
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
78
        })?;
79
        Ok(if r.status() == 204 { None } else { Some(r) })
80
    }
81
82
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
83
    /// away for a few minutes, as while a deploy restarts it, so a job's
84
    /// declines and uploads outlast it.
85
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
86
        let url = format!("{}/api/v1{path}", self.base);
87
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
88
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
89
        })?;
90
        Ok(if r.status() == 204 { None } else { Some(r) })
91
    }
92
93
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
94
        match self.post("/flow/claim", req)? {
95
            Some(r) => Ok(Some(r.into_json()?)),
96
            None => Ok(None),
97
        }
98
    }
99
100
    /// Hand job `job` back, saying why this runner will not run it.
101
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
102
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
103
        Ok(())
104
    }
105
106
    /// Hand back a job this runner holds as it leaves (oxygenflow#76): the
107
    /// hub queues it again ahead of new work for another runner.
108
    pub fn release(&self, job: i64, reason: &str) -> Result<()> {
109
        self.post_patiently(&format!("/flow/jobs/{job}/release"), &Decline { reason: reason.into() })?;
110
        Ok(())
111
    }
112
113
    /// Say this runner is there while it claims nothing: its slots are full,
114
    /// or it is draining (oxygenhub#243). Once, no retries: the next comes
115
    /// soon. A hub without the endpoint (404) is not an error.
116
    /// A ten-minute certificate for `identity`, asked with a job's token
117
    /// (keyless, oxygenhub#241), and the identity of the hub's key that
118
    /// issued it, from `/.well-known/oxyhub-key`.
119
    pub fn certify(
120
        &self,
121
        job_token: &str,
122
        identity: &oxy_core::identity::Identity,
123
        proof: &oxy_core::identity::Signature,
124
    ) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> {
125
        use oxy_core::dsse::{base64, base64_decode};
126
        let body = serde_json::json!({
127
            "identity": base64(&identity.encode()),
128
            "proof": base64(&oxy_core::cbor::encode(&proof.to_value())),
129
            "scope": "seal",
130
        });
131
        let url = format!("{}/api/v1/keyless/certify", self.base);
132
        let r = self
133
            .agent
134
            .post(&url)
135
            .set("Authorization", &format!("Bearer {job_token}"))
136
            .send_json(body)
137
            .map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?;
138
        let v: serde_json::Value = r.into_json()?;
139
        let cert = base64_decode(v["certificate"].as_str().unwrap_or(""))
140
            .context("the hub's answer has no certificate")
141
            .and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?;
142
        let mut bytes = Vec::new();
143
        std::io::Read::read_to_end(
144
            &mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(),
145
            &mut bytes,
146
        )?;
147
        let issuer = oxy_core::identity::Identity::decode(&bytes)?;
148
        if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() {
149
            bail!("the certificate is not signed by the hub's published key");
150
        }
151
        if cert.cert.key_id() != identity.hash() {
152
            bail!("the hub certified another key");
153
        }
154
        Ok((cert, issuer))
155
    }
156
157
    pub fn heartbeat(&self) -> Result<()> {
158
        match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) {
159
            Ok(_) => Ok(()),
160
            Err(e) if format!("{e:#}").contains("404") => Ok(()),
161
            Err(e) => Err(e),
162
        }
163
    }
164
165
    /// The update policy for this runner and the build it should run
166
    /// (oxygenhub#322); `None` from a hub without runner updates.
167
    pub fn update_answer(&self) -> Result<Option<flow_core::api::UpdateAnswer>> {
168
        let url = format!("{}/api/v1/flow/runner/update", self.base);
169
        match self.agent.get(&url).set("Authorization", &format!("Bearer {}", self.token)).call() {
170
            Ok(r) => Ok(Some(r.into_json()?)),
171
            Err(ureq::Error::Status(404, _)) => Ok(None),
172
            Err(e) => Err(anyhow::anyhow!("{url}: {e}")),
173
        }
174
    }
175
176
    /// Tell the hub what this runner did about its update.
177
    pub fn report_update(&self, note: &str) -> Result<()> {
178
        self.post_with("/flow/runner/update", &flow_core::api::UpdateReport { note: note.to_string() }, &[])?;
179
        Ok(())
180
    }
181
182
    /// Download an offered build to `to`, refusing it unless its BLAKE3
183
    /// is the one the hub attests and it is the size it said.
184
    pub fn download_build(&self, offer: &flow_core::api::UpdateOffer, to: &Path) -> Result<()> {
185
        use std::io::{Read, Write};
186
        let url = format!("{}{}", self.base, offer.url);
187
        let r = self.agent.get(&url).timeout(Duration::from_secs(1800)).call().map_err(|e| anyhow::anyhow!("{url}: {e}"))?;
188
        let part = to.with_extension("partial");
189
        let mut out = std::fs::File::create(&part).with_context(|| format!("writing {}", part.display()))?;
190
        let (mut hasher, mut n, mut buf) = (blake3::Hasher::new(), 0u64, vec![0u8; 1 << 16]);
191
        let mut body = r.into_reader().take(offer.size + 1);
192
        loop {
193
            let k = body.read(&mut buf)?;
194
            if k == 0 {
195
                break;
196
            }
197
            hasher.update(&buf[..k]);
198
            out.write_all(&buf[..k])?;
199
            n += k as u64;
200
        }
201
        drop(out);
202
        let got = hasher.finalize().to_hex().to_string();
203
        if n != offer.size || got != offer.blake3 {
204
            let _ = std::fs::remove_file(&part);
205
            bail!("{} does not match what the hub attests ({n} bytes, BLAKE3 {}…)", offer.asset, &got[..12]);
206
        }
207
        std::fs::rename(&part, to)?;
208
        Ok(())
209
    }
210
211
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
212
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
213
        Ok(())
214
    }
215
216
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
217
    /// a blob of the job's repository and answers with its ObjectRef.
218
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
219
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
220
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
221
            self.agent
222
                .post(&url)
223
                .timeout(Duration::from_secs(600))
224
                .set("Authorization", &format!("Bearer {}", self.token))
225
                .set("Content-Type", "application/octet-stream")
226
                .send_bytes(bytes)
227
                .map_err(Box::new)
228
        })?;
229
        let v: serde_json::Value = r.into_json()?;
230
        v["blob"].as_str().map(String::from).context("the hub named no blob")
231
    }
232
233
    /// Keep a checkpoint of job `job` (oxygenflow#77) as its runner hands
234
    /// it back: the hub answers with its blob, which the next attempt's
235
    /// claim names.
236
    pub fn save_checkpoint(&self, job: i64, bytes: &[u8]) -> Result<String> {
237
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
238
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
239
            self.agent
240
                .post(&url)
241
                .timeout(Duration::from_secs(600))
242
                .set("Authorization", &format!("Bearer {}", self.token))
243
                .set("Content-Type", "application/octet-stream")
244
                .send_bytes(bytes)
245
                .map_err(Box::new)
246
        })?;
247
        let v: serde_json::Value = r.into_json()?;
248
        v["blob"].as_str().map(String::from).context("the hub named no blob")
249
    }
250
251
    /// The checkpoint an earlier attempt of job `job` saved (oxygenflow#77),
252
    /// which the caller checks against the blob its claim names.
253
    pub fn checkpoint(&self, job: i64) -> Result<Vec<u8>> {
254
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
255
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
256
            self.agent
257
                .get(&url)
258
                .timeout(Duration::from_secs(600))
259
                .set("Authorization", &format!("Bearer {}", self.token))
260
                .call()
261
                .map_err(Box::new)
262
        })?;
263
        let mut bytes = Vec::new();
264
        std::io::Read::read_to_end(&mut std::io::Read::take(r.into_reader(), crate::checkpoint::MAX_BYTES as u64 + 1), &mut bytes)?;
265
        Ok(bytes)
266
    }
267
268
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
269
    /// producing job's artifact, which the caller checks against the blob the
270
    /// claim names.
271
    pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> {
272
        let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base);
273
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
274
            self.agent
275
                .get(&url)
276
                .timeout(Duration::from_secs(600))
277
                .set("Authorization", &format!("Bearer {}", self.token))
278
                .call()
279
                .map_err(Box::new)
280
        })?;
281
        let mut bytes = Vec::new();
282
        let mut reader = r.into_reader();
283
        std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?;
284
        if bytes.len() > MAX_INPUT_BYTES {
285
            bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20);
286
        }
287
        Ok(bytes)
288
    }
289
290
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
291
    /// the hub keeps it as a blob and answers with its ObjectRef. The
292
    /// report's manifest follows through [`Hub::artifact`].
293
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
294
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
295
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
296
            self.agent
297
                .post(&url)
298
                .timeout(Duration::from_secs(600))
299
                .set("Authorization", &format!("Bearer {}", self.token))
300
                .set("Content-Type", "application/octet-stream")
301
                .set("X-Flow-Report-Path", path)
302
                .send_bytes(bytes)
303
                .map_err(Box::new)
304
        })?;
305
        let v: serde_json::Value = r.into_json()?;
306
        v["blob"].as_str().map(String::from).context("the hub named no blob")
307
    }
308
309
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
310
    /// cannot be reached or fails (5xx), try again after `first_wait`,
311
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
312
    /// already, say) is final. A result lost here leaves the job running
313
    /// on the hub until its time limit.
314
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
315
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
316
        let deadline = std::time::Instant::now() + patience;
317
        let mut wait = first_wait;
318
        loop {
319
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
320
                Ok(_) => return Ok(()),
321
                Err(ureq::Error::Status(code, r)) if code < 500 => {
322
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
323
                }
324
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
325
                Err(e) => e.to_string(),
326
            };
327
            if std::time::Instant::now() + wait > deadline {
328
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
329
            }
330
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
331
            std::thread::sleep(wait);
332
            wait = (wait * 2).min(Duration::from_secs(60));
333
        }
334
    }
335
}
336
337
/// How long a runner keeps trying to report a job's end.
338
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
339
340
/// Where a job's result waits until the hub has it, so a runner that
341
/// restarts still reports it.
342
fn pending_path(home: &Home, job: i64) -> PathBuf {
343
    home.root.join("pending").join(format!("{job}.json"))
344
}
345
346
/// Report `f`, keeping it on disk until the hub has it.
347
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
348
    let path = pending_path(home, job);
349
    let kept = std::fs::create_dir_all(path.parent().unwrap())
350
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
351
        .is_ok();
352
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
353
        Ok(()) => {}
354
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
355
    }
356
    if kept {
357
        let _ = std::fs::remove_file(&path);
358
    }
359
}
360
361
/// Results a previous run of the runner could not report, to send again.
362
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
363
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
364
        return Vec::new();
365
    };
366
    dir.filter_map(|e| {
367
        let p = e.ok()?.path();
368
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
369
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
370
        Some((job, f))
371
    })
372
    .collect()
373
}
374
375
/// `flow runner register`: exchange a one-time registration token from
376
/// the hub for this runner's credential, binding the runner's signing key,
377
/// and keep hub, credential and name in runner.toml.
378
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
379
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
380
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
381
    }
382
    let key = home.runner_key(name)?;
383
    let hub = hub.trim_end_matches('/').to_string();
384
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
385
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
386
    let req = Register {
387
        token: token.trim().to_string(),
388
        name: name.to_string(),
389
        identity: hex::encode(key.identity.encode()),
390
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
391
    };
392
    let url = format!("{hub}/api/v1/flow/runners/register");
393
    let got: Registered = match agent.post(&url).send_json(&req) {
394
        Ok(r) => r.into_json()?,
395
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
396
        Err(e) => bail!("{url}: {e}"),
397
    };
398
    let mut text = format!(
399
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
400
        got.credential, got.name
401
    );
402
    if let Some(ca) = &ca {
403
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
404
    }
405
    if !labels.is_empty() {
406
        text.push_str(&format!("labels = {labels:?}\n"));
407
    }
408
    let path = home.root.join(crate::home::RUNNER_TOML);
409
    {
410
        use std::io::Write;
411
        let tmp = path.with_extension("toml.tmp");
412
        let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
413
        f.write_all(text.as_bytes())?;
414
        f.sync_all()?;
415
        std::fs::rename(&tmp, &path)?;
416
    }
417
    Ok(got)
418
}
419
420
/// Waits before trying a hub request again after a server error.
421
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
422
423
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
424
/// all, longer than a hub takes to restart.
425
/// Check an input's bytes against the blob the producing job's receipt
426
/// names, then write them at its path under `tree` (the workspace's `/src`).
427
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> {
428
    let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy"))
429
    else {
430
        bail!("path {:?} is not a clean path under /src", i.path);
431
    };
432
    let blob = Repo::blob_ref(bytes);
433
    if blob.to_hex() != i.blob {
434
        bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]);
435
    }
436
    let dest = tree.join(rel);
437
    if let Some(dir) = dest.parent() {
438
        std::fs::create_dir_all(dir)?;
439
    }
440
    std::fs::write(&dest, bytes)?;
441
    Ok(blob.0)
442
}
443
444
/// The largest input a job takes: the hub's artifact limit.
445
pub const MAX_INPUT_BYTES: usize = 512 << 20;
446
447
const PATIENT_DELAYS: [Duration; 15] = {
448
    let mut d = [Duration::from_secs(15); 15];
449
    d[0] = Duration::from_secs(1);
450
    d[1] = Duration::from_secs(2);
451
    d[2] = Duration::from_secs(4);
452
    d[3] = Duration::from_secs(8);
453
    d
454
};
455
456
/// Send a request until the hub answers, waiting `delays` in turn after a
457
/// server error (5xx: nothing was committed) and, when `connection`, after
458
/// the hub could not be reached at all. A refusal (4xx) is final, as a
459
/// [`Refused`] the caller can look at.
460
fn retrying(
461
    url: &str,
462
    delays: &[Duration],
463
    connection: bool,
464
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
465
) -> Result<ureq::Response> {
466
    let mut delays = delays.iter();
467
    loop {
468
        let why = match send().map_err(|e| *e) {
469
            Ok(r) => return Ok(r),
470
            Err(ureq::Error::Status(code, r)) if code < 500 => {
471
                let text = r.into_string().unwrap_or_default();
472
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
473
            }
474
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
475
            Err(e) if connection => e.to_string(),
476
            Err(e) => bail!("{url}: {e}"),
477
        };
478
        match delays.next() {
479
            Some(d) => std::thread::sleep(*d),
480
            None => bail!("{url}: {why}"),
481
        }
482
    }
483
}
484
485
/// What the runner may run at once (flow.toml `[runner]`): at most
486
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
487
#[derive(Debug, Default, PartialEq, Eq)]
488
pub struct Budget {
489
    pub max_jobs: usize,
490
    pub memory_mib: Option<u32>,
491
    pub cpus: Option<u32>,
492
}
493
494
/// What is in use now.
495
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
496
pub struct Used {
497
    pub jobs: usize,
498
    pub memory_mib: u32,
499
    pub cpus: u32,
500
}
501
502
impl Budget {
503
    pub fn of(cfg: &RunnerConfig) -> Budget {
504
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
505
    }
506
507
    /// The room to ask the hub for, or `None` when no job more can start.
508
    /// A lone job may use the whole budget even if it asks for more, so a
509
    /// large sandbox is never starved.
510
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
511
        if used.jobs >= self.max_jobs {
512
            return None;
513
        }
514
        if used.jobs == 0 {
515
            return Some((None, None));
516
        }
517
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
518
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
519
        if mem == Some(0) || cpus == Some(0) {
520
            return None;
521
        }
522
        Some((mem, cpus))
523
    }
524
}
525
526
/// The isolation tiers this machine can honour now (spec §11.1): the
527
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
528
/// flow.toml pins Firecracker; the syscall tier when this binary may use
529
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
530
/// `Err` says why there are none; the reasons for missing tiers come back
531
/// with the ones there are.
532
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
533
    let mut have = Vec::new();
534
    let mut missing = Vec::new();
535
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
536
    match (kvm, home.config.microvm.is_some()) {
537
        _ if !cfg!(target_os = "linux") => missing.push("microvm: the tier runs only on Linux (Firecracker and KVM)".to_string()),
538
        (Ok(_), true) => have.push("microvm".to_string()),
539
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
540
        (Err(e), _) => missing.push(format!(
541
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
542
        )),
543
    }
544
    match crate::syscall::available() {
545
        Ok(()) => have.push("syscall".to_string()),
546
        Err(e) => missing.push(format!("syscall: {e}")),
547
    }
548
    // wasmtime runs on every machine this binary does (oxygenflow#75).
549
    have.push("wasm".to_string());
550
    if have.is_empty() {
551
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
552
    }
553
    Ok((have, missing))
554
}
555
556
/// Fetch `claim`'s image from the hub when this home lacks it
557
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
558
/// leaves it missing, and the preflight declines the job as before.
559
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) -> Option<u64> {
560
    let hash = &claim.sandbox.rootfs;
561
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
562
        return None;
563
    }
564
    let note = |text: String| {
565
        eprintln!("job {}: {text}", claim.job_id);
566
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
567
    };
568
    // One fetch of an image at a time: two jobs wanting it wait for one.
569
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
570
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
571
    if home.rootfs(hash).is_file() {
572
        return None;
573
    }
574
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
575
        Ok(c) => c,
576
        Err(e) => {
577
            note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12]));
578
            return None;
579
        }
580
    };
581
    let fetching = Instant::now();
582
    let fetched = match swarm {
583
        Some(sw) => {
584
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
585
            sw.fetch(home, hash)
586
        }
587
        None => {
588
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
589
            client.fetch(home, hash)
590
        }
591
    };
592
    let took = fetching.elapsed().as_millis() as u64;
593
    match fetched {
594
        Ok(Some(m)) => note(format!(
595
            "image {}: fetched {} chunks, {}{} in {:.1} s",
596
            &hash[..12],
597
            m.chunks,
598
            crate::images::human(m.bytes),
599
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
600
            m.elapsed.as_secs_f64()
601
        )),
602
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
603
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
604
    }
605
    Some(took)
606
}
607
608
/// Why this runner cannot run `claim` now (spec §11.5), checked before
609
/// anything is fetched: the tier, the architecture, the image and the
610
/// memory. The hub chose it from what the runner last said, which may be
611
/// out of date.
612
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
613
    let sb = &claim.sandbox;
614
    match sb.runtime {
615
        flow_core::Runtime::Microvm => {
616
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
617
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
618
            }
619
            if home.config.microvm.is_none() {
620
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
621
            }
622
        }
623
        flow_core::Runtime::Syscall => {
624
            if let Err(e) = crate::syscall::available() {
625
                return Some(format!("the syscall tier cannot run here: {e}"));
626
            }
627
        }
628
        // No image and no architecture: only the memory matters.
629
        flow_core::Runtime::Wasm => {
630
            return available_memory_mib()
631
                .filter(|free| *free < u64::from(sb.memory_mib))
632
                .map(|free| format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
633
        }
634
    }
635
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
636
}
637
638
/// What stops `sb` running on this home's images, on a machine of
639
/// architecture `here` with `free_mib` of memory free.
640
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
641
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
642
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
643
    }
644
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
645
    let image = home.rootfs(&sb.rootfs);
646
    if !image.is_file() {
647
        return Some(format!("rootfs {short} is not on this runner"));
648
    }
649
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
650
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
651
    }
652
    if let Err(e) = home.verified(&image, &sb.rootfs) {
653
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
654
    }
655
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
656
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
657
    }
658
    None
659
}
660
661
/// The memory to claim with: the budget's room, never more than the host
662
/// has free now. An idle runner's budget is unlimited (a lone job may use
663
/// it all), so without this it was offered sandboxes it then declined for
664
/// want of memory, one after another, having fetched their images first
665
/// (an 8 GiB job on a 7 GB machine).
666
fn claim_memory(room: Option<u32>, host_free_mib: Option<u64>) -> Option<u32> {
667
    match (room, host_free_mib) {
668
        (Some(r), Some(h)) => Some(r.min(u32::try_from(h).unwrap_or(u32::MAX))),
669
        (None, Some(h)) => Some(u32::try_from(h).unwrap_or(u32::MAX)),
670
        (room, None) => room,
671
    }
672
}
673
674
/// `MemAvailable` from /proc/meminfo, in MiB.
675
fn available_memory_mib() -> Option<u64> {
676
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
677
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
678
    Some(kib / 1024)
679
}
680
681
pub fn serve(home: &Home) -> Result<()> {
682
    drain_on_stop();
683
    crate::update::note_runner(&home.root);
684
    // `flow runner update` swapped a new build in and asks this one to
685
    // leave with the notice it gave (#85).
686
    #[cfg(unix)]
687
    {
688
        let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t;
689
        unsafe { libc::signal(libc::SIGUSR1, handler) };
690
    }
691
    if let Some(p) = crate::update::pending(&home.root) {
692
        // On trial: a build that does not reach the hub in time goes back.
693
        let root = home.root.clone();
694
        std::thread::spawn(move || {
695
            std::thread::sleep(crate::update::PROVE_WITHIN);
696
            if crate::update::pending(&root).is_some_and(|now| now.to == p.to && now.at_ms == p.at_ms) {
697
                let why = format!("did not reach the hub within {} minutes", crate::update::PROVE_WITHIN.as_secs() / 60);
698
                if crate::update::roll_back(&root, &p, &why).is_ok() {
699
                    std::process::exit(1);
700
                }
701
            }
702
        });
703
    }
704
    let served = serve_until_drained(home);
705
    crate::update::forget_runner(&home.root);
706
    // Left for a new build: on Windows, end as a failure, so a Task
707
    // Scheduler task set to restart on failure starts it (systemd's
708
    // Restart=always starts it after a clean exit).
709
    if cfg!(windows) && served.is_ok() && LEFT_FOR_UPDATE.load(std::sync::atomic::Ordering::SeqCst) {
710
        eprintln!("flow runner: left for the new build; exiting 75 so it is started");
711
        std::process::exit(75);
712
    }
713
    served
714
}
715
716
/// Set once the runner leaves for a new build.
717
static LEFT_FOR_UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
718
719
/// Set by SIGUSR1: a new build is in place; leave for it.
720
static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
721
722
#[cfg(unix)]
723
extern "C" fn on_update(_: libc::c_int) {
724
    UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
725
}
726
727
/// The runner as a library, as the Android app runs it (oxygenhub-android#8):
728
/// no signal handlers; [`stop`] drains it, and this returns once the jobs
729
/// it holds are done and reported. It may be started again afterwards.
730
pub fn serve_embedded(home: &Home) -> Result<()> {
731
    STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
732
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = None;
733
    serve_until_drained(home)
734
}
735
736
/// A departure ([`leave_within`]): why, and when the runner must be gone.
737
#[derive(Clone, Debug)]
738
struct Leave {
739
    reason: String,
740
    by: Instant,
741
}
742
743
/// Set once the runner is leaving: a job whose expected rest fits before
744
/// the deadline finishes; the others are stopped and handed back
745
/// (oxygenflow#76).
746
static LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None);
747
748
fn leaving() -> Option<String> {
749
    LEAVING.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(|l| l.reason.clone())
750
}
751
752
/// Ask a runner started with [`serve_embedded`] to leave now: claim nothing
753
/// more, stop the jobs it holds and hand them back to the hub, which queues
754
/// them ahead of new work for another runner (oxygenflow#76), then return.
755
/// For a departure with no time to finish: a phone off the allowed network.
756
pub fn leave(reason: &str) {
757
    leave_within(reason, Duration::ZERO);
758
}
759
760
/// [`leave`], with `notice`: a job the hub expects to finish within it
761
/// (the claim's `expected_ms`, less the time it has run) finishes and is
762
/// reported; the others, and any still running when the notice is up, are
763
/// handed back. For a phone taken off charge, a spot reclaim.
764
pub fn leave_within(reason: &str, notice: Duration) {
765
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = Some(Leave { reason: reason.to_string(), by: Instant::now() + notice });
766
    stop();
767
}
768
769
/// Ask a runner started with [`serve_embedded`] to drain: claim nothing
770
/// more, finish and report what it holds, then return (oxygenflow#76's
771
/// drain, for a phone taken off charge or off the allowed network).
772
pub fn stop() {
773
    STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
774
}
775
776
fn serve_until_drained(home: &Home) -> Result<()> {
777
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
778
    // A runner offers only what it can run: a machine without KVM claims
779
    // nothing rather than taking MicroVM jobs and failing them (#36).
780
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
781
    for why in &missing {
782
        eprintln!("flow runner: not offering {why}");
783
    }
784
    let hub = Hub::new(&cfg)?;
785
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
786
    let budget = Budget::of(&cfg);
787
    let key = home.runner_key(&name)?;
788
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
789
    // peers still runs jobs, fetching images from the hub alone.
790
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
791
        eprintln!("flow runner: not joining the swarm: {e:#}");
792
        None
793
    });
794
    eprintln!(
795
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
796
        cfg.hub,
797
        budget.max_jobs,
798
        runtimes.join(", "),
799
        key.identity.hash().to_hex()
800
    );
801
    if let Some(addr) = &cfg.metrics {
802
        crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone());
803
    }
804
    // The manifests of images held here that the hub keeps none of, so
805
    // peers in this runner's site can fetch them without anyone pushing
806
    // them (oxygenflow#94): at start and every ten minutes, which also
807
    // catches an image imported while it runs. Only a runner that serves
808
    // its peers has anything to offer.
809
    if swarm.is_some() {
810
        let (root, cfg) = (home.root.clone(), cfg.clone());
811
        std::thread::spawn(move || loop {
812
            let offered = Home::locate(Some(&root))
813
                .and_then(|home| crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?.offer_held(&home));
814
            match offered {
815
                Ok(0) => {}
816
                Ok(n) => eprintln!("flow runner: sent the hub the manifests of {n} image(s) only runners hold"),
817
                Err(e) => eprintln!("flow runner: offering held images: {e:#}"),
818
            }
819
            std::thread::sleep(Duration::from_secs(600));
820
        });
821
    }
822
    let used = std::sync::Mutex::new(Used::default());
823
    let mut idle = Duration::from_secs(2);
824
    let pending = report_pending(home);
825
    // The jobs held now, told to the hub at each claim so it can end the
826
    // ones this runner lost: a job is held from its claim until its result
827
    // is reported.
828
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
829
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
830
    std::thread::scope(|scope| {
831
        for (job, f) in pending {
832
            let (hub, held) = (&hub, &held);
833
            scope.spawn(move || {
834
                eprintln!("job {job}: reporting the result kept from before the restart");
835
                report_finish(home, hub, job, &f);
836
                held.lock().unwrap().remove(&job);
837
            });
838
        }
839
        // The speed (spec §11.1): measured now, and again every 15 minutes
840
        // while no job runs, so a job never shares the CPU with it.
841
        let measure = || {
842
            let (speed, ms) = crate::facts::bench();
843
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
844
            speed
845
        };
846
        let mut speed = measure();
847
        let mut benched = Instant::now();
848
        let mut said_draining = false;
849
        // When the hub last heard from us (oxygenhub#243): a claim, or a
850
        // heartbeat while there is no room to claim.
851
        let mut heard = Instant::now();
852
        let beat = |heard: &mut Instant| {
853
            if heard.elapsed() >= HUB_HEARTBEAT {
854
                if let Err(e) = hub.heartbeat() {
855
                    eprintln!("flow runner {name}: heartbeat: {e:#}");
856
                }
857
                *heard = Instant::now();
858
            }
859
        };
860
        let mut on_trial = crate::update::pending(&home.root).is_some();
861
        // Runner updates (oxygenhub#322): asked of the hub once it has
862
        // answered a claim, then every 15 minutes.
863
        let mut update_asked: Option<Instant> = None;
864
        let mut update_said = String::new();
865
        loop {
866
            if !stopping() && crate::update::drain_requested(&home.root) {
867
                eprintln!("flow runner {name}: asked to drain (flow runner drain)");
868
                stop();
869
            }
870
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) || crate::update::requested(&home.root) {
871
                let p = crate::update::pending(&home.root);
872
                let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs));
873
                let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to));
874
                eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs());
875
                LEFT_FOR_UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
876
                leave_within(&format!("updating to {to}"), notice);
877
            }
878
            // Draining (oxygenflow#50): told to stop, claim nothing more and
879
            // stop once every job held is done and reported.
880
            if stopping() {
881
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
882
                if jobs == 0 && holding == 0 {
883
                    eprintln!("flow runner {name}: drained, stopping");
884
                    break;
885
                }
886
                if !said_draining {
887
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
888
                    said_draining = true;
889
                }
890
                beat(&mut heard);
891
                std::thread::sleep(Duration::from_millis(500));
892
                continue;
893
            }
894
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
895
                speed = measure();
896
                benched = Instant::now();
897
            }
898
            let room = budget.room(*used.lock().unwrap());
899
            let Some((free_memory_mib, free_cpus)) = room else {
900
                beat(&mut heard);
901
                std::thread::sleep(Duration::from_millis(500));
902
                continue;
903
            };
904
            heard = Instant::now();
905
            let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times();
906
            let req = ClaimRequest {
907
                runner: name.clone(),
908
                runtimes: runtimes.clone(),
909
                free_memory_mib: claim_memory(free_memory_mib, available_memory_mib()),
910
                free_cpus,
911
                info: Some(RunnerInfo {
912
                    version: env!("OXY_VERSION_LONG").into(),
913
                    ..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
914
                }),
915
                // Read each time: an image imported while the runner runs counts.
916
                images: Some(home.images()),
917
                image_archs: Some(home.image_archs()),
918
                holding: Some(held.lock().unwrap().iter().copied().collect()),
919
                fetches_images: true,
920
                facts: Some(crate::facts::now(home, Some(speed))),
921
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
922
                caches: Some(cache_times.keys().cloned().collect()),
923
                cache_times: Some(cache_times),
924
            };
925
            let answer = hub.claim(&req);
926
            if on_trial && answer.is_ok() {
927
                crate::update::proven(&home.root);
928
                on_trial = false;
929
            }
930
            if answer.is_ok() && update_asked.is_none_or(|t| t.elapsed() >= UPDATE_EVERY) {
931
                update_asked = Some(Instant::now());
932
                if let Some(note) = crate::update::take_note(&home.root) {
933
                    let _ = hub.report_update(&note);
934
                }
935
                consider_update(home, &hub, &mut update_said);
936
            }
937
            match answer {
938
                Ok(Some(claim)) => {
939
                    idle = Duration::from_secs(2);
940
                    crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
941
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
942
                    {
943
                        let mut u = used.lock().unwrap();
944
                        u.jobs += 1;
945
                        u.memory_mib += mem;
946
                        u.cpus += cpus;
947
                    }
948
                    held.lock().unwrap().insert(claim.job_id);
949
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
950
                    scope.spawn(move || {
951
                        let id = claim.job_id;
952
                        let image_ms = fetch_image(home, hub, cfg, swarm, &claim);
953
                        if let Some(why) = preflight(home, &claim) {
954
                            eprintln!("job {id}: declined: {why}");
955
                            crate::metrics::DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
956
                            if let Err(e) = hub.decline(id, &why) {
957
                                eprintln!("job {id}: decline: {e:#}");
958
                            }
959
                            held.lock().unwrap().remove(&id);
960
                            let mut u = used.lock().unwrap();
961
                            u.jobs -= 1;
962
                            u.memory_mib -= mem;
963
                            u.cpus -= cpus;
964
                            return;
965
                        }
966
                        eprintln!(
967
                            "job {id}: {} {} on {}",
968
                            claim.repo,
969
                            claim.job.name,
970
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
971
                        );
972
                        crate::metrics::RUNNING.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
973
                        let finish = run_claim(home, hub, cfg, key, &claim, image_ms).unwrap_or_else(|e| Finish {
974
                            exit_code: None,
975
                            duration_ms: 0,
976
                            receipt: None,
977
                            signed_receipt: None,
978
                            error: Some(format!("{e:#}")),
979
                            peak_disk_mib: None,
980
                            peak_memory_mib: None,
981
                            phases: Default::default(),
982
                        });
983
                        eprintln!(
984
                            "job {id}: exit {:?}{}",
985
                            finish.exit_code,
986
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
987
                        );
988
                        crate::metrics::RUNNING.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
989
                        let cancelled = finish.error.as_deref() == Some(CANCELLED);
990
                        crate::metrics::finished(finish.exit_code, finish.error.as_deref(), finish.duration_ms, cancelled);
991
                        match leaving() {
992
                            // Stopped as the runner leaves: another runner takes it.
993
                            Some(why) if cancelled => {
994
                                eprintln!("job {id}: handed back: {why}");
995
                                if let Err(e) = hub.release(id, &why) {
996
                                    eprintln!("job {id}: handing back: {e:#}; the hub requeues it once this runner's lease lapses");
997
                                }
998
                            }
999
                            _ if !cancelled => report_finish(home, hub, id, &finish),
1000
                            _ => {}
1001
                        }
1002
                        held.lock().unwrap().remove(&id);
1003
                        let mut u = used.lock().unwrap();
1004
                        u.jobs -= 1;
1005
                        u.memory_mib -= mem;
1006
                        u.cpus -= cpus;
1007
                    });
1008
                }
1009
                Ok(None) => std::thread::sleep(idle),
1010
                Err(e) => {
1011
                    eprintln!("claim: {e:#}");
1012
                    std::thread::sleep(idle);
1013
                    idle = (idle * 2).min(Duration::from_secs(60));
1014
                }
1015
            }
1016
        }
1017
        Ok(())
1018
    })
1019
}
1020
1021
/// Pack and keep a handed-back job's checkpoints (oxygenflow#77); what
1022
/// happened goes to its log, and a failure only means the next attempt
1023
/// starts again.
1024
#[allow(clippy::too_many_arguments)]
1025
fn save_checkpoint(hub: &Hub, id: i64, job: &flow_core::Job, scratch: &Path, dir_tier: bool, wasm: bool, mut say: impl FnMut(String)) {
1026
    if !dir_tier {
1027
        say("checkpoint: only the WASM and syscall tiers save one yet; the next attempt starts again".into());
1028
        return;
1029
    }
1030
    let dirs: Vec<(String, PathBuf)> = job
1031
        .checkpoints
1032
        .iter()
1033
        .filter_map(|c| crate::checkpoint::host_dir(c, job, &scratch.join("tree"), scratch, wasm).map(|d| (c.clone(), d)))
1034
        .collect();
1035
    match crate::checkpoint::pack(&dirs).and_then(|(bytes, n)| Ok((hub.save_checkpoint(id, &bytes)?, n))) {
1036
        Ok((blob, n)) => {
1037
            say(format!("checkpoint {}: {} files, {} bytes, kept for the next attempt", &blob[..12.min(blob.len())], n.files, n.bytes))
1038
        }
1039
        Err(e) => say(format!("checkpoint: not kept ({e:#}); the next attempt starts again")),
1040
    }
1041
}
1042
1043
/// How many times the runner was told to stop (SIGTERM or SIGINT).
1044
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1045
1046
extern "C" fn on_stop(_: libc::c_int) {
1047
    // A second stop means now: only async-signal-safe calls here.
1048
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
1049
        unsafe { libc::_exit(143) };
1050
    }
1051
}
1052
1053
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
1054
/// so restarting a runner never kills the job it runs: the unit's
1055
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
1056
fn drain_on_stop() {
1057
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
1058
    unsafe {
1059
        libc::signal(libc::SIGTERM, handler);
1060
        libc::signal(libc::SIGINT, handler);
1061
    }
1062
}
1063
1064
fn stopping() -> bool {
1065
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
1066
}
1067
1068
/// Batches records to the hub every second (or every 200 lines). A quiet
1069
/// job's sink still asks the hub every few seconds whether the job runs,
1070
/// with an empty batch; once the hub answers 409 the job is not wanted.
1071
struct LogSink<'a> {
1072
    hub: &'a Hub,
1073
    job: i64,
1074
    buf: Vec<Record>,
1075
    last: Instant,
1076
    gone: bool,
1077
    /// Set when the runner leaves ([`LEAVING`]): the job stops, to be
1078
    /// handed back, unless its expected rest fits the notice.
1079
    leaving: &'a std::sync::Mutex<Option<Leave>>,
1080
    /// When the job started, and how long the hub expects it to take.
1081
    started: Instant,
1082
    expected: Option<Duration>,
1083
}
1084
1085
/// The most log lines sent at once, as a busy job's sink flushes them.
1086
const BATCH: usize = 200;
1087
1088
/// The most log lines kept while the hub cannot be reached.
1089
const MAX_UNSENT: usize = 50_000;
1090
1091
/// How often a quiet job asks whether it was cancelled.
1092
const HEARTBEAT: Duration = Duration::from_secs(5);
1093
1094
impl microvm::Sink for LogSink<'_> {
1095
    fn record(&mut self, r: Record) {
1096
        self.push(r)
1097
    }
1098
1099
    fn cancelled(&mut self) -> bool {
1100
        let leave = self.leaving.lock().unwrap_or_else(|p| p.into_inner()).clone();
1101
        if let Some(l) = leave {
1102
            // Finished if what is left of it fits the notice; else handed
1103
            // back as the runner leaves, what the job said so far first.
1104
            let now = Instant::now();
1105
            let fits = self.expected.is_some_and(|e| now + e.saturating_sub(self.started.elapsed()) <= l.by);
1106
            if !fits || now >= l.by {
1107
                self.flush();
1108
                return true;
1109
            }
1110
        }
1111
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
1112
            self.send();
1113
        }
1114
        self.gone
1115
    }
1116
}
1117
1118
impl LogSink<'_> {
1119
    fn push(&mut self, mut r: Record) {
1120
        if self.gone {
1121
            return;
1122
        }
1123
        if r.text.len() > MAX_RECORD_TEXT {
1124
            let mut cut = MAX_RECORD_TEXT;
1125
            while !r.text.is_char_boundary(cut) {
1126
                cut -= 1;
1127
            }
1128
            r.text.truncate(cut);
1129
        }
1130
        self.buf.push(r);
1131
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
1132
            self.flush();
1133
        }
1134
    }
1135
1136
    fn flush(&mut self) {
1137
        if !self.buf.is_empty() {
1138
            self.send();
1139
        }
1140
        self.last = Instant::now();
1141
    }
1142
1143
    /// Send what is buffered, even nothing, in batches of at most
1144
    /// `BATCH` lines.
1145
    fn send(&mut self) {
1146
        loop {
1147
            if self.gone {
1148
                return;
1149
            }
1150
            let n = self.buf.len().min(BATCH);
1151
            let batch: Vec<Record> = self.buf.drain(..n).collect();
1152
            match self.hub.log(self.job, batch.clone()) {
1153
                Ok(()) => {}
1154
                Err(e) if job_gone(&e) => self.gone = true,
1155
                Err(e) => {
1156
                    // The hub is away (a restart, say): keep the lines for
1157
                    // the next batch, up to a bound, so the log loses none
1158
                    // (oxygenflow#49).
1159
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
1160
                    let mut kept = batch;
1161
                    kept.append(&mut self.buf);
1162
                    let over = kept.len().saturating_sub(MAX_UNSENT);
1163
                    if over > 0 {
1164
                        kept.drain(..over);
1165
                        kept.insert(
1166
                            0,
1167
                            Record {
1168
                                t: 0,
1169
                                stream: "flow".into(),
1170
                                text: format!("({over} log lines lost while the hub could not be reached)"),
1171
                            },
1172
                        );
1173
                    }
1174
                    self.buf = kept;
1175
                    break;
1176
                }
1177
            }
1178
            if self.buf.is_empty() {
1179
                break;
1180
            }
1181
        }
1182
        self.last = Instant::now();
1183
    }
1184
}
1185
1186
/// How often a runner with no room to claim tells the hub it is there:
1187
/// well inside the hub's 90 s before it shows a runner offline.
1188
const HUB_HEARTBEAT: Duration = Duration::from_secs(30);
1189
1190
/// How often a runner asks the hub whether it should run another build.
1191
const UPDATE_EVERY: Duration = Duration::from_secs(15 * 60);
1192
1193
/// Notice a runner gives itself to leave for an update the hub offered.
1194
const UPDATE_NOTICE: Duration = Duration::from_secs(600);
1195
1196
/// Ask the hub for this runner's update, and act on it: download the build
1197
/// it offers, check it against the hub's BLAKE3 and as a runner build, and
1198
/// install it, or for a dry run say what would happen. What it did goes to
1199
/// the hub once per outcome (`said`).
1200
fn consider_update(home: &Home, hub: &Hub, said: &mut String) {
1201
    let answer = match hub.update_answer() {
1202
        Ok(Some(a)) => a,
1203
        Ok(None) => return,
1204
        Err(e) => {
1205
            eprintln!("flow runner: asking for an update: {e:#}");
1206
            return;
1207
        }
1208
    };
1209
    let Some(offer) = answer.offer else { return };
1210
    let mut say = |note: String| {
1211
        if *said != note {
1212
            eprintln!("flow runner: {note}");
1213
            if let Err(e) = hub.report_update(&note) {
1214
                eprintln!("flow runner: telling the hub: {e:#}");
1215
            }
1216
            *said = note;
1217
        }
1218
    };
1219
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
1220
        return say(format!("refuses flow {}: flow.toml says [runner.update] allow = false", offer.version));
1221
    }
1222
    let dir = home.root.join("updates");
1223
    let file = dir.join(format!("flow-{}", offer.version));
1224
    let fresh = std::fs::read(&file).is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == offer.blake3);
1225
    if !fresh {
1226
        if let Err(e) = std::fs::create_dir_all(&dir).map_err(anyhow::Error::from).and_then(|_| hub.download_build(&offer, &file)) {
1227
            return say(format!("flow {}: not fetched: {e:#}", offer.version));
1228
        }
1229
    }
1230
    let exe = match std::env::current_exe() {
1231
        Ok(e) => e,
1232
        Err(e) => return say(format!("flow {}: cannot find its own binary: {e}", offer.version)),
1233
    };
1234
    match crate::update::install(home, &exe, &file, UPDATE_NOTICE, answer.policy.dry_run, offer.older) {
1235
        Ok(done) if answer.policy.dry_run => say(format!("dry run: flow {} checks out here and would replace flow {}", done.to, done.from)),
1236
        Ok(done) => {
1237
            let _ = std::fs::remove_file(&file);
1238
            say(format!("installed flow {} (was {}); leaving within {} s to start it", done.to, done.from, UPDATE_NOTICE.as_secs()))
1239
        }
1240
        Err(e) => say(format!("flow {}: not installed: {e:#}", offer.version)),
1241
    }
1242
}
1243
1244
/// Held while a job takes its cache disks and images its workspace (see
1245
/// `run_claim`): one job at a time on the runner's disk and cache pool.
1246
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
1247
1248
/// Held while the first clone of any repository passes its token through
1249
/// the process environment.
1250
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
1251
1252
/// One lock per cached repository: fetches into it, and exports from it,
1253
/// never overlap, while jobs of other repositories go on. A slow repository
1254
/// (one kept in a bucket, say) once held every job on the runner waiting
1255
/// behind a single global lock.
1256
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
1257
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
1258
        std::sync::OnceLock::new();
1259
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
1260
    locks.entry(name.to_string()).or_default().clone()
1261
}
1262
1263
/// Seconds, to one decimal place, for the job's log.
1264
fn secs(d: std::time::Duration) -> String {
1265
    format!("{:.1} s", d.as_secs_f64())
1266
}
1267
1268
/// `image_ms`: how long fetching its image took, when the runner lacked it.
1269
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim, image_ms: Option<u64>) -> Result<Finish> {
1270
    let started = Instant::now();
1271
    let mut sink = LogSink {
1272
        hub,
1273
        job: claim.job_id,
1274
        buf: Vec::new(),
1275
        last: Instant::now(),
1276
        gone: false,
1277
        leaving: &LEAVING,
1278
        started: Instant::now(),
1279
        expected: claim.expected_ms.map(Duration::from_millis),
1280
    };
1281
    let note =
1282
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
1283
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
1284
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
1285
    // Preparing a workspace: fetches into a cached repository, and exports
1286
    // from it, are one job at a time per repository (`repo_lock`); taking
1287
    // cache disks and imaging the workspace are one job at a time on the
1288
    // runner (`PREPARE`); a first clone, whose token goes through the process
1289
    // environment, one at a time (`CLONE`).
1290
    let main_lock = repo_lock(&claim.repo);
1291
    let asked = Instant::now();
1292
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
1293
    if asked.elapsed().as_secs() >= 2 {
1294
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
1295
    }
1296
    let fetching = Instant::now();
1297
    let repo = cached_repo(home, cfg, claim)?;
1298
    let fetched = fetching.elapsed();
1299
    let mut preparing = None;
1300
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
1301
    let _ = std::fs::remove_dir_all(&scratch);
1302
    std::fs::create_dir_all(scratch.join("tree"))?;
1303
    let result = (|| -> Result<Finish> {
1304
        let exporting = Instant::now();
1305
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
1306
        main_guard.take();
1307
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
1308
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
1309
        // workspace and of the scope hash.
1310
        for sub in &claim.subrepos {
1311
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
1312
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
1313
            }
1314
            note(
1315
                &mut sink,
1316
                format!(
1317
                    "fetching subrepo {} at {} for {}",
1318
                    sub.repo,
1319
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
1320
                    sub.path
1321
                ),
1322
            );
1323
            let sub_lock = repo_lock(&sub.repo);
1324
            let asked = Instant::now();
1325
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
1326
            if asked.elapsed().as_secs() >= 2 {
1327
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
1328
            }
1329
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
1330
            let sub_heads: Vec<ChangeHash> =
1331
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
1332
            let dir = scratch.join("tree").join(&sub.path);
1333
            std::fs::create_dir_all(&dir)?;
1334
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
1335
            drop(sub_guard);
1336
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
1337
        }
1338
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
1339
        // Inputs (oxygenflow#58): each producing job's artifact, checked
1340
        // against the blob its receipt names, then put at its path. Not part
1341
        // of the scope: they are the run's, not the repository's.
1342
        let mut input_blobs = Vec::new();
1343
        for (index, i) in claim.inputs.iter().enumerate() {
1344
            let what = format!("input {} from {}", i.artifact, i.from);
1345
            let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?;
1346
            let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?;
1347
            note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12]));
1348
            input_blobs.push(blob);
1349
        }
1350
        // The syscall tier runs on the tree itself; a microVM gets an image.
1351
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
1352
        let wasm_tier = claim.sandbox.runtime == flow_core::Runtime::Wasm;
1353
        // The syscall and WASM tiers see the job's files as a directory;
1354
        // the MicroVM tier as an ext4 image.
1355
        let dir_tier = syscall_tier || wasm_tier;
1356
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
1357
        // Disk mounts that name a cache start from a kept disk (#28): a
1358
        // microVM takes an image already in place as it is, the syscall tier
1359
        // a directory (#70). Taken before the workspace is imaged: the
1360
        // sources the disk was built from give unchanged files their old
1361
        // times back (#52).
1362
        let asked = Instant::now();
1363
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
1364
        if asked.elapsed().as_secs() >= 2 {
1365
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
1366
        }
1367
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
1368
        let form = if dir_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
1369
        let cache_ctx =
1370
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
1371
        let mut cached: Vec<(usize, String, String)> = Vec::new();
1372
        let mut built_from: Option<crate::cache::Sources> = None;
1373
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
1374
            let Some(name) = &m.cache else { continue };
1375
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
1376
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
1377
                Some(t) => {
1378
                    if built_from.is_none() {
1379
                        built_from = t.sources;
1380
                    }
1381
                    t.how
1382
                }
1383
                None => format!("cache {name}: none kept yet, an empty disk"),
1384
            };
1385
            note(&mut sink, format!("{} ({})", how, m.target));
1386
            // What the job reads is seen when the disk is pruned (#72).
1387
            if let Err(e) = crate::cache::forget_reads(form, &crate::cache::disk_path(form, &scratch, i)) {
1388
                note(&mut sink, format!("cache {name}: {e:#}"));
1389
            }
1390
            cached.push((i, key, how));
1391
        }
1392
        // What the job reads or writes on its cache from now on is what it
1393
        // used; the rest may be pruned before the cache is kept (#72).
1394
        let cache_started = if cached.is_empty() { std::time::SystemTime::now() } else { flow_init::prune::start_clock() };
1395
        let sources = if cached.is_empty() {
1396
            crate::cache::Sources::new()
1397
        } else {
1398
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
1399
            if built_from.is_some() {
1400
                note(
1401
                    &mut sink,
1402
                    format!(
1403
                        "cache: {restored} of {} files and directories unchanged since the cached build, their times kept",
1404
                        sources.len()
1405
                    ),
1406
                );
1407
            }
1408
            sources
1409
        };
1410
        // An earlier attempt's checkpoint (oxygenflow#77): back in place,
1411
        // over the cache, before the same exec runs again; the receipt
1412
        // lists it among the job's inputs.
1413
        if let Some(blob) = &claim.checkpoint {
1414
            if dir_tier {
1415
                let bytes = hub.checkpoint(claim.job_id).context("fetching the checkpoint")?;
1416
                if Repo::blob_ref(&bytes).to_hex() != *blob {
1417
                    bail!("the checkpoint fetched is not blob {}, as the claim names", &blob[..12.min(blob.len())]);
1418
                }
1419
                let place = |root: &str| crate::checkpoint::host_dir(root, &job, &scratch.join("tree"), &scratch, wasm_tier);
1420
                let n = crate::checkpoint::unpack(&bytes, &job.checkpoints, place)?;
1421
                note(
1422
                    &mut sink,
1423
                    format!("restored checkpoint {}: {} files, {} bytes, from the attempt handed back", &blob[..12], n.files, n.bytes),
1424
                );
1425
                input_blobs.push(Repo::blob_ref(&bytes).0);
1426
            } else {
1427
                note(&mut sink, "a checkpoint was saved, but only the WASM and syscall tiers restore one yet: starting again".into());
1428
            }
1429
        }
1430
        if !dir_tier {
1431
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
1432
            std::fs::remove_dir_all(scratch.join("tree"))?;
1433
        }
1434
        preparing.take();
1435
        // Cancelled while it was being prepared: no sandbox.
1436
        if microvm::Sink::cancelled(&mut sink) {
1437
            return Ok(cancelled());
1438
        }
1439
        if wasm_tier {
1440
            note(&mut sink, format!("starting WASM (wasmtime {}): {} MiB", crate::wasm::WASMTIME, claim.sandbox.memory_mib));
1441
        } else {
1442
            note(
1443
                &mut sink,
1444
                format!(
1445
                    "{}: {} {}, {} MiB, rootfs {}",
1446
                    if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
1447
                    claim.sandbox.cpus,
1448
                    if syscall_tier { "CPU" } else { "vCPU" },
1449
                    claim.sandbox.memory_mib,
1450
                    &claim.sandbox.rootfs[..12]
1451
                ),
1452
            );
1453
        }
1454
        let timeout = Duration::from_secs(claim.timeout_s);
1455
        // Where the time goes (oxygenflow#35): what came before the
1456
        // sandbox, the sandbox, and what comes after.
1457
        let prepare_ms = started.elapsed().as_millis() as u64;
1458
        let sandbox_started = Instant::now();
1459
        let mut outcome = if wasm_tier {
1460
            crate::wasm::run(&claim.sandbox, &job, &scratch.join("tree"), &scratch, timeout, &mut sink)?
1461
        } else if syscall_tier {
1462
            let group = format!("job-{}", claim.job_id);
1463
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
1464
        } else {
1465
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
1466
        };
1467
        let run_ms = sandbox_started.elapsed().as_millis() as u64;
1468
        if outcome.cancelled {
1469
            // Handed back as the runner leaves: its checkpoints first, for
1470
            // the next attempt (oxygenflow#77).
1471
            if leaving().is_some() && !job.checkpoints.is_empty() {
1472
                save_checkpoint(hub, claim.job_id, &job, &scratch, dir_tier, wasm_tier, |t| note(&mut sink, t));
1473
            }
1474
            return Ok(cancelled());
1475
        }
1476
        // The receipt says what a cached disk started from.
1477
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
1478
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
1479
        for (i, key, how) in &cached {
1480
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
1481
        }
1482
        // A passing job's artifacts, read from its disks and uploaded; a
1483
        // declared artifact that cannot be kept fails the job. A failing
1484
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
1485
        // its receipt, and one it cannot keep is only noted.
1486
        let mut outputs = vec![];
1487
        let mut output_error = None;
1488
        let passed = outcome.exit_code == Some(0);
1489
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
1490
        for o in wanted {
1491
            if o.kind == flow_core::OutputKind::Report {
1492
                let read = if dir_tier {
1493
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
1494
                    crate::outputs::read_report_dir(&dir, &inner)
1495
                } else {
1496
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
1497
                    crate::outputs::read_report(&image, &inner, &scratch)
1498
                };
1499
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
1500
                match kept {
1501
                    Ok((blob, files, bytes)) => {
1502
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
1503
                        let hex = blob.to_hex();
1504
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
1505
                        if passed {
1506
                            outputs.push(blob.0);
1507
                        }
1508
                    }
1509
                    Err(e) if passed => {
1510
                        output_error = Some(format!("report {}: {e:#}", o.name));
1511
                        break;
1512
                    }
1513
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
1514
                }
1515
                continue;
1516
            }
1517
            let read = if dir_tier {
1518
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
1519
                crate::outputs::read_dir_file(&dir, &inner)
1520
            } else {
1521
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
1522
                crate::outputs::read(&image, &inner, &scratch)
1523
            };
1524
            let kept = read.and_then(|bytes| {
1525
                let blob = Repo::blob_ref(&bytes);
1526
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
1527
                if named != blob.to_hex() {
1528
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
1529
                }
1530
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
1531
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
1532
                Ok(blob.0)
1533
            });
1534
            match kept {
1535
                Ok(b) if passed => outputs.push(b),
1536
                Ok(_) => {}
1537
                Err(e) if passed => {
1538
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
1539
                    break;
1540
                }
1541
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
1542
            }
1543
        }
1544
        if let Some(e) = &output_error {
1545
            note(&mut sink, e.clone());
1546
        }
1547
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
1548
            job_name: claim.job.name.clone(),
1549
            pipeline_hash: hex32(&claim.pipeline_hash),
1550
            scope_tree_hash: scope_hash,
1551
            inputs: input_blobs.clone(),
1552
            environment: outcome.environment.hash(),
1553
            outputs,
1554
            exit_code: code as u32,
1555
            duration_ms: outcome.duration_ms,
1556
        });
1557
        let signed_receipt = match &receipt {
1558
            Some(r) => {
1559
                let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?;
1560
                if cfg.keyless {
1561
                    match sign_keyless(&repo, &heads, cfg, hub, claim, r) {
1562
                        Ok(k) => signed = k,
1563
                        Err(e) => {
1564
                            note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}"))
1565
                        }
1566
                    }
1567
                }
1568
                Some(hex::encode(signed.encode()))
1569
            }
1570
            None => None,
1571
        };
1572
        // Measured before a cached disk leaves the scratch directory.
1573
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1574
        // A passing job's cached disks are pruned and kept (#72); a
1575
        // failing job's are thrown away, so nothing it wrote reaches a
1576
        // later job.
1577
        let kept = passed && output_error.is_none();
1578
        for (i, key, _) in &cached {
1579
            let disk = crate::cache::disk_path(form, &scratch, *i);
1580
            let m = disk_mounts[*i];
1581
            let prune = crate::cache::Prune {
1582
                started: cache_started,
1583
                percent: cfg.cache_prune_percent.unwrap_or(flow_init::prune::DEFAULT_PERCENT),
1584
                guest: outcome.pruned.iter().find(|(t, _)| *t == m.target).map(|(_, p)| p),
1585
            };
1586
            match pool.settle(&cache_ctx, m, key, &disk, &sources, kept, &prune) {
1587
                Ok(Some(line)) => note(&mut sink, line),
1588
                Ok(None) => {}
1589
                Err(e) => note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" })),
1590
            }
1591
        }
1592
        let total_ms = started.elapsed().as_millis() as u64;
1593
        let mut phases: std::collections::BTreeMap<String, u64> =
1594
            [("prepare", prepare_ms), ("run", run_ms), ("finish", total_ms.saturating_sub(prepare_ms + run_ms))]
1595
                .into_iter()
1596
                .map(|(k, v)| (k.to_string(), v))
1597
                .collect();
1598
        if let Some(ms) = image_ms {
1599
            phases.insert("image".into(), ms);
1600
        }
1601
        Ok(Finish {
1602
            phases,
1603
            exit_code: outcome.exit_code,
1604
            duration_ms: total_ms,
1605
            receipt,
1606
            signed_receipt,
1607
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
1608
            // What the job took (oxygenflow#41): its disks are sparse files
1609
            // in the scratch directory, so their blocks now are their peak.
1610
            peak_disk_mib,
1611
            peak_memory_mib: outcome.peak_memory_mib,
1612
        })
1613
    })();
1614
    let _ = workspace::remove_tree(&scratch);
1615
    sink.flush();
1616
    result
1617
}
1618
1619
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
1620
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
1621
/// the claim, so the runner vouches only for what it fetched.
1622
/// Upload a report's files, then its manifest under the report's name, and
1623
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
1624
/// the manifest's blob, the file count and their total size.
1625
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
1626
    let mut listed = Vec::with_capacity(files.len());
1627
    let mut total = 0u64;
1628
    for (path, bytes) in files {
1629
        let blob = Repo::blob_ref(bytes).to_hex();
1630
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
1631
        if named != blob {
1632
            bail!("the hub kept {path} as {named}, not {blob}");
1633
        }
1634
        total += bytes.len() as u64;
1635
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
1636
    }
1637
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
1638
    let blob = Repo::blob_ref(&manifest);
1639
    let named = hub.artifact(job, name, &manifest)?;
1640
    if named != blob.to_hex() {
1641
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
1642
    }
1643
    Ok((blob, files.len(), total))
1644
}
1645
1646
fn sign_receipt(
1647
    repo: &Repo,
1648
    heads: &[ChangeHash],
1649
    cfg: &RunnerConfig,
1650
    key: &RunnerKey,
1651
    claim: &Claim,
1652
    receipt: &Receipt,
1653
) -> Result<oxy_core::receipt::SignedReceipt> {
1654
    let state = state_of(heads);
1655
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1656
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
1657
}
1658
1659
/// Sign `receipt` with a fresh key the hub certifies for this job now,
1660
/// just before signing, so the certificate covers the moment the hub
1661
/// accepts it (keyless, oxygenhub#241). The key is dropped with the
1662
/// receipt signed: nothing long-lived signs.
1663
fn sign_keyless(
1664
    repo: &Repo,
1665
    heads: &[ChangeHash],
1666
    cfg: &RunnerConfig,
1667
    hub: &Hub,
1668
    claim: &Claim,
1669
    receipt: &Receipt,
1670
) -> Result<oxy_core::receipt::SignedReceipt> {
1671
    let sk = oxy_core::identity::SecretKey::generate();
1672
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
1673
    let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id);
1674
    let id = sk.identity(&name, "", now);
1675
    let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?;
1676
    let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?;
1677
    let state = state_of(heads);
1678
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1679
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer))
1680
}
1681
1682
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
1683
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1684
    use oxy_core::hash::{hash_object, ObjectType};
1685
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
1686
}
1687
1688
fn hex32(s: &str) -> [u8; 32] {
1689
    let mut out = [0; 32];
1690
    if let Ok(v) = hex::decode(s) {
1691
        if v.len() == 32 {
1692
            out.copy_from_slice(&v);
1693
        }
1694
    }
1695
    out
1696
}
1697
1698
/// Whether a network error is worth another try: DNS, a refused or reset
1699
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
1700
/// hub restarts (oxygenhub#111).
1701
fn transient(msg: &str) -> bool {
1702
    let m = msg.to_lowercase();
1703
    [
1704
        "dns failed",
1705
        "name resolution",
1706
        "connection refused",
1707
        "connection reset",
1708
        "timed out",
1709
        "timeout",
1710
        "http 502",
1711
        "http 503",
1712
        "http 504",
1713
        "broken pipe",
1714
        "unexpected eof",
1715
    ]
1716
    .iter()
1717
    .any(|k| m.contains(k))
1718
}
1719
1720
/// Run `f` again after a transient network error, waiting `waits` between
1721
/// tries (about a minute in all), and give up on any other error at once.
1722
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
1723
    let mut tries = waits.iter();
1724
    loop {
1725
        match f() {
1726
            Ok(v) => return Ok(v),
1727
            Err(e) => match tries.next() {
1728
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
1729
                _ => return Err(e),
1730
            },
1731
        }
1732
    }
1733
}
1734
1735
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
1736
const FETCH_WAITS: [Duration; 5] =
1737
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
1738
1739
/// The runner's copy of a repository, fetched up to date with the job's token.
1740
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
1741
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
1742
}
1743
1744
/// The runner's copy of `repo`, cloned or fetched with `token`.
1745
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
1746
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
1747
    let set_remote = |repo: &mut Repo| -> Result<()> {
1748
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
1749
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
1750
        if let Some(ca) = &cfg.ca {
1751
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
1752
        }
1753
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
1754
    };
1755
    if dir.join(".oxy").is_dir() {
1756
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
1757
        set_remote(&mut repo)?;
1758
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
1759
        return Ok(repo);
1760
    }
1761
    // First job for this repository: clone with the job's token, which goes
1762
    // through the process environment, so one clone at a time.
1763
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
1764
    std::env::set_var("OXY_TOKEN", token);
1765
    if let Some(ca) = &cfg.ca {
1766
        std::env::set_var("OXY_CA", ca);
1767
    }
1768
    let cloned = with_retries(&FETCH_WAITS, || {
1769
        let _ = std::fs::remove_dir_all(&dir);
1770
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
1771
    });
1772
    std::env::remove_var("OXY_TOKEN");
1773
    std::env::remove_var("OXY_CA");
1774
    let mut repo = cloned.map_err(|e| {
1775
        let _ = std::fs::remove_dir_all(&dir);
1776
        anyhow::anyhow!("clone {}: {e}", clone_url)
1777
    })?;
1778
    set_remote(&mut repo)?;
1779
    Ok(repo)
1780
}
1781
1782
#[cfg(test)]
1783
mod tests {
1784
1785
    #[test]
1786
    fn an_input_is_placed_only_when_it_is_what_the_receipt_names() {
1787
        let dir = tempfile::tempdir().unwrap();
1788
        let bytes = b"the reviewed plan";
1789
        let blob = Repo::blob_ref(bytes).to_hex();
1790
        let input = |path: &str, blob: &str| flow_core::api::ArtifactInput {
1791
            artifact: "tfplan".into(),
1792
            from: "plan".into(),
1793
            path: path.into(),
1794
            blob: blob.into(),
1795
        };
1796
        let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap();
1797
        assert_eq!(hex::encode(got), blob);
1798
        assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes);
1799
        let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err();
1800
        assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}");
1801
        assert!(!dir.path().join("other").exists(), "nothing written when it does not match");
1802
        for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] {
1803
            assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}");
1804
        }
1805
    }
1806
1807
    use super::*;
1808
1809
    #[test]
1810
    fn a_runner_names_what_stops_a_sandbox() {
1811
        let dir = tempfile::tempdir().unwrap();
1812
        let home = Home::locate(Some(dir.path())).unwrap();
1813
        let bytes = b"not really ext4";
1814
        let hash = blake3::hash(bytes).to_hex().to_string();
1815
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
1816
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
1817
        }))
1818
        .unwrap();
1819
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
1820
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
1821
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
1822
        sb.rootfs = hash.clone();
1823
        assert_eq!(problem(&sb, Some(4096)), None);
1824
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
1825
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
1826
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
1827
        sb.arch = Some("aarch64".into());
1828
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
1829
        sb.arch = None;
1830
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
1831
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
1832
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
1833
    }
1834
1835
    #[test]
1836
    fn a_runner_reads_an_unrecorded_images_architecture_and_declines_another() {
1837
        // An image fetched or imported with no recorded architecture, built
1838
        // for riscv64: the runner reads its shell, records it, and declines
1839
        // instead of running it and failing (spec §11.2, §11.5).
1840
        let dir = tempfile::tempdir().unwrap();
1841
        let home = Home::locate(Some(dir.path().join("home").as_path())).unwrap();
1842
        let tree = dir.path().join("tree");
1843
        std::fs::create_dir_all(tree.join("bin")).unwrap();
1844
        let mut elf = vec![0u8; 64];
1845
        elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
1846
        elf[18..20].copy_from_slice(&0xF3u16.to_le_bytes());
1847
        std::fs::write(tree.join("bin/sh"), &elf).unwrap();
1848
        let img = dir.path().join("rootfs.ext4");
1849
        let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status();
1850
        if !made.is_ok_and(|s| s.success()) {
1851
            eprintln!("mkfs.ext4 not found: skipped");
1852
            return;
1853
        }
1854
        let hash = crate::home::hash_file(&img).unwrap();
1855
        std::fs::create_dir_all(home.rootfs(&hash).parent().unwrap()).unwrap();
1856
        std::fs::rename(&img, home.rootfs(&hash)).unwrap();
1857
        assert!(!home.image_manifest(&hash).exists());
1858
        let sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
1859
            "name": "s", "runtime": "syscall", "rootfs": hash, "memory_mib": 64, "cpus": 1, "network": "isolated"
1860
        }))
1861
        .unwrap();
1862
        assert_eq!(
1863
            sandbox_problem(&home, &sb, "x86_64", None).unwrap(),
1864
            format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])
1865
        );
1866
        assert_eq!(sandbox_problem(&home, &sb, "riscv64", None), None, "a riscv64 runner takes it");
1867
        // Learned: recorded once, and reported with the runner's claims.
1868
        let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(home.image_manifest(&hash)).unwrap()).unwrap();
1869
        assert_eq!(manifest["arch"], "riscv64");
1870
        assert_eq!(home.image_archs().get(&hash).map(String::as_str), Some("riscv64"));
1871
    }
1872
1873
    #[test]
1874
    fn transient_network_errors_are_tried_again_and_others_are_not() {
1875
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
1876
        assert!(transient("HTTP 503: the hub is restarting"));
1877
        assert!(!transient("HTTP 404: no such repository"));
1878
        let mut n = 0;
1879
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
1880
            n += 1;
1881
            if n < 3 {
1882
                Err(anyhow::anyhow!("connection refused"))
1883
            } else {
1884
                Ok(7)
1885
            }
1886
        });
1887
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
1888
        let mut m = 0;
1889
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
1890
            m += 1;
1891
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
1892
        });
1893
        assert!(got.is_err());
1894
        assert_eq!(m, 1, "a refusal is not tried again");
1895
        let mut k = 0;
1896
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
1897
            k += 1;
1898
            Err(anyhow::anyhow!("timed out"))
1899
        });
1900
        assert!(got.is_err());
1901
        assert_eq!(k, 3, "gives up after the last wait");
1902
    }
1903
1904
    /// A hub that answers each request with the next of `codes`, counting them.
1905
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1906
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
1907
    }
1908
1909
    /// A hub at `listener` answering each request with the next of `codes`.
1910
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1911
        use std::io::{Read, Write};
1912
        let addr = listener.local_addr().unwrap();
1913
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1914
        let count = seen.clone();
1915
        std::thread::spawn(move || {
1916
            for code in codes {
1917
                let (mut s, _) = listener.accept().unwrap();
1918
                // Read the whole request (headers, then Content-Length bytes
1919
                // of body) before answering: closing early would reset the
1920
                // connection while the client is still sending.
1921
                let mut req = Vec::new();
1922
                let mut buf = [0u8; 8192];
1923
                loop {
1924
                    let n = s.read(&mut buf).unwrap_or(0);
1925
                    if n == 0 {
1926
                        break;
1927
                    }
1928
                    req.extend_from_slice(&buf[..n]);
1929
                    let text = String::from_utf8_lossy(&req);
1930
                    if let Some(end) = text.find("\r\n\r\n") {
1931
                        let len = text[..end]
1932
                            .lines()
1933
                            .find_map(|l| {
1934
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1935
                            })
1936
                            .unwrap_or(0);
1937
                        if req.len() >= end + 4 + len {
1938
                            break;
1939
                        }
1940
                    }
1941
                }
1942
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1943
                let reason = if code == 204 { "No Content" } else { "Error" };
1944
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
1945
            }
1946
        });
1947
        (format!("http://{addr}"), seen)
1948
    }
1949
1950
    /// A hub that answers one request with `body` (status 200) and hands
1951
    /// back what it was sent.
1952
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
1953
        use std::io::{Read, Write};
1954
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1955
        let addr = listener.local_addr().unwrap();
1956
        let (tx, rx) = std::sync::mpsc::channel();
1957
        std::thread::spawn(move || {
1958
            let (mut s, _) = listener.accept().unwrap();
1959
            let mut req = Vec::new();
1960
            let mut buf = [0u8; 65536];
1961
            loop {
1962
                let n = s.read(&mut buf).unwrap_or(0);
1963
                if n == 0 {
1964
                    break;
1965
                }
1966
                req.extend_from_slice(&buf[..n]);
1967
                let text = String::from_utf8_lossy(&req);
1968
                if let Some(end) = text.find("\r\n\r\n") {
1969
                    let len = text[..end]
1970
                        .lines()
1971
                        .find_map(|l| {
1972
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1973
                        })
1974
                        .unwrap_or(0);
1975
                    if req.len() >= end + 4 + len {
1976
                        break;
1977
                    }
1978
                }
1979
            }
1980
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
1981
            let _ = write!(
1982
                s,
1983
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1984
                body.len()
1985
            );
1986
        });
1987
        (format!("http://{addr}"), rx)
1988
    }
1989
1990
    #[test]
1991
    fn registering_binds_the_key_and_keeps_the_credential_private() {
1992
        use std::os::unix::fs::PermissionsExt;
1993
        let dir = tempfile::tempdir().unwrap();
1994
        let home = Home::locate(Some(dir.path())).unwrap();
1995
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
1996
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
1997
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
1998
        let req = sent.recv().unwrap();
1999
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
2000
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
2001
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
2002
        let key = home.runner_key("plex").unwrap();
2003
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
2004
        let path = dir.path().join(crate::home::RUNNER_TOML);
2005
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
2006
        // The runner then starts from runner.toml.
2007
        let again = Home::locate(Some(dir.path())).unwrap();
2008
        let cfg = again.config.runner.unwrap();
2009
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
2010
        assert_eq!(cfg.labels, ["gpu"]);
2011
    }
2012
2013
    /// A hub that answers certify with a certificate `sign_as` signs for
2014
    /// `cert_key`, and publishes `published` as its key: two requests.
2015
    fn fake_certify_hub(
2016
        sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey),
2017
        cert_key: oxy_core::identity::Identity,
2018
        published: oxy_core::identity::Identity,
2019
    ) -> String {
2020
        use std::io::{Read, Write};
2021
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2022
        let addr = listener.local_addr().unwrap();
2023
        std::thread::spawn(move || {
2024
            for _ in 0..2 {
2025
                let Ok((mut s, _)) = listener.accept() else { return };
2026
                let mut req = Vec::new();
2027
                let mut buf = [0u8; 65536];
2028
                loop {
2029
                    let n = s.read(&mut buf).unwrap_or(0);
2030
                    if n == 0 {
2031
                        break;
2032
                    }
2033
                    req.extend_from_slice(&buf[..n]);
2034
                    let text = String::from_utf8_lossy(&req);
2035
                    if let Some(end) = text.find("\r\n\r\n") {
2036
                        let len = text[..end]
2037
                            .lines()
2038
                            .find_map(|l| {
2039
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
2040
                            })
2041
                            .unwrap_or(0);
2042
                        if req.len() >= end + 4 + len {
2043
                            break;
2044
                        }
2045
                    }
2046
                }
2047
                let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string();
2048
                let body: Vec<u8> = if line.contains("/keyless/certify") {
2049
                    let cert = oxy_core::cert::SignedCertificate::sign(
2050
                        oxy_core::cert::Certificate {
2051
                            key: cert_key.clone(),
2052
                            issuer: sign_as.0.hash(),
2053
                            subject: "repo:andy/demo:bookmark:main".into(),
2054
                            claims: vec![],
2055
                            scope: oxy_core::identity::Scope::SEAL,
2056
                            not_before: 1,
2057
                            expires: 600_001,
2058
                            grant: None,
2059
                        },
2060
                        &sign_as.0,
2061
                        &sign_as.1,
2062
                    );
2063
                    serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes()
2064
                } else {
2065
                    published.encode()
2066
                };
2067
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
2068
                let _ = s.write_all(&body);
2069
            }
2070
        });
2071
        format!("http://{addr}")
2072
    }
2073
2074
    /// keyless (oxygenhub#241): the runner takes a certificate only when it
2075
    /// is for its fresh key and signed by the key the hub publishes.
2076
    #[test]
2077
    fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() {
2078
        use oxy_core::identity::SecretKey;
2079
        let hub_sk = SecretKey::generate();
2080
        let hub_id = hub_sk.identity("hub", "", 1);
2081
        let sk = SecretKey::generate();
2082
        let id = sk.identity("earth-1 job 7", "", 1);
2083
        let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap();
2084
2085
        let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone());
2086
        let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap();
2087
        assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash()));
2088
2089
        let mallory_sk = SecretKey::generate();
2090
        let mallory = mallory_sk.identity("mallory", "", 1);
2091
        let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone());
2092
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
2093
        assert!(e.contains("not signed by the hub's published key"), "{e}");
2094
2095
        let other = SecretKey::generate().identity("other", "", 1);
2096
        let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id);
2097
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
2098
        assert!(e.contains("another key"), "{e}");
2099
    }
2100
2101
    fn hub_at(base: String) -> Hub {
2102
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
2103
    }
2104
2105
    /// oxygenhub#322: an offered build is kept only if it is the bytes the
2106
    /// hub attests; a peer or a proxy cannot change what is installed.
2107
    #[test]
2108
    fn a_build_is_kept_only_if_it_matches_the_hubs_blake3() {
2109
        let (body, other) = (b"#!flow build 0.3.2".to_vec(), b"#!something else!!".to_vec());
2110
        let serve = |bytes: Vec<u8>| {
2111
            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2112
            let addr = listener.local_addr().unwrap();
2113
            std::thread::spawn(move || {
2114
                use std::io::{Read, Write};
2115
                let (mut s, _) = listener.accept().unwrap();
2116
                let mut buf = [0u8; 4096];
2117
                let _ = s.read(&mut buf);
2118
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len());
2119
                let _ = s.write_all(&bytes);
2120
            });
2121
            format!("http://{addr}")
2122
        };
2123
        let offer = flow_core::api::UpdateOffer {
2124
            version: "0.3.2".into(),
2125
            asset: "flow-linux-x86_64".into(),
2126
            url: "/andy/oxygenflow/asset/1/flow-linux-x86_64".into(),
2127
            blake3: blake3::hash(&body).to_hex().to_string(),
2128
            size: body.len() as u64,
2129
            ..Default::default()
2130
        };
2131
        let dir = tempfile::tempdir().unwrap();
2132
        let to = dir.path().join("flow-0.3.2");
2133
        hub_at(serve(body.clone())).download_build(&offer, &to).unwrap();
2134
        assert_eq!(std::fs::read(&to).unwrap(), body);
2135
        std::fs::remove_file(&to).unwrap();
2136
        let e = hub_at(serve(other)).download_build(&offer, &to).unwrap_err();
2137
        assert!(e.to_string().contains("does not match what the hub attests"), "{e:#}");
2138
        assert!(!to.exists() && !to.with_extension("partial").exists(), "nothing of it is left");
2139
    }
2140
2141
    /// oxygenhub#71: a job's result is offered until the hub takes it,
2142
    /// through server errors, and a refusal ends the trying at once.
2143
    #[test]
2144
    fn a_result_is_reported_until_the_hub_takes_it() {
2145
        let f = Finish {
2146
            exit_code: Some(0),
2147
            duration_ms: 1,
2148
            receipt: None,
2149
            signed_receipt: None,
2150
            error: None,
2151
            peak_disk_mib: None,
2152
            peak_memory_mib: None,
2153
            phases: Default::default(),
2154
        };
2155
        let quick = Duration::from_millis(10);
2156
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
2157
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
2158
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
2159
        let (base, seen) = fake_hub(vec![409]);
2160
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
2161
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
2162
    }
2163
2164
    #[test]
2165
    fn server_errors_are_tried_again_and_client_errors_are_not() {
2166
        let quick = [Duration::from_millis(10); 3];
2167
        let (base, seen) = fake_hub(vec![500, 503, 204]);
2168
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
2169
        assert!(r.is_none());
2170
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
2171
2172
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
2173
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
2174
        assert!(e.contains("HTTP 500"), "{e}");
2175
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
2176
2177
        let (base, seen) = fake_hub(vec![401]);
2178
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
2179
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
2180
    }
2181
2182
    static NOT_LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None);
2183
2184
    /// oxygenflow#76: once the runner leaves, a job's sink says it is
2185
    /// cancelled, having sent what the job said so far, and the job is
2186
    /// handed back to the hub.
2187
    #[test]
2188
    fn a_leaving_runner_stops_its_job_and_hands_it_back() {
2189
        use microvm::Sink;
2190
        let leaving = std::sync::Mutex::new(None);
2191
        let (base, seen) = fake_hub(vec![204, 204]);
2192
        let hub = hub_at(base);
2193
        let mut sink = LogSink {
2194
            hub: &hub,
2195
            job: 7,
2196
            buf: Vec::new(),
2197
            last: Instant::now(),
2198
            gone: false,
2199
            leaving: &leaving,
2200
            started: Instant::now(),
2201
            expected: None,
2202
        };
2203
        sink.buf.push(Record { t: 1, stream: "out".into(), text: "step 1".into() });
2204
        assert!(!sink.cancelled());
2205
        *leaving.lock().unwrap() = Some(Leave { reason: "off Wi-Fi".into(), by: Instant::now() });
2206
        assert!(sink.cancelled());
2207
        assert!(sink.buf.is_empty(), "flushed before the job stops");
2208
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
2209
        hub.release(7, "off Wi-Fi").unwrap();
2210
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
2211
    }
2212
2213
    /// oxygenflow#76: with notice, a job the hub expects to finish within
2214
    /// it runs on; one that will not, or whose time is unknown, is handed
2215
    /// back at once, and any job still running when the notice is up.
2216
    #[test]
2217
    fn a_job_that_fits_the_notice_finishes_and_the_others_go_back() {
2218
        use microvm::Sink;
2219
        let (base, _) = fake_hub(vec![204; 8]);
2220
        let hub = hub_at(base);
2221
        let leaving = std::sync::Mutex::new(Some(Leave { reason: "off charge".into(), by: Instant::now() + Duration::from_secs(60) }));
2222
        let sink = |expected: Option<u64>, ran: u64| LogSink {
2223
            hub: &hub,
2224
            job: 1,
2225
            buf: Vec::new(),
2226
            last: Instant::now(),
2227
            gone: false,
2228
            leaving: &leaving,
2229
            started: Instant::now() - Duration::from_secs(ran),
2230
            expected: expected.map(Duration::from_secs),
2231
        };
2232
        assert!(!sink(Some(70), 30).cancelled(), "40 s left of it, within a minute: it finishes");
2233
        assert!(sink(Some(600), 30).cancelled(), "nearly ten minutes left: handed back");
2234
        assert!(sink(None, 0).cancelled(), "no idea how long: handed back");
2235
        *leaving.lock().unwrap() = Some(Leave { reason: "off charge".into(), by: Instant::now() - Duration::from_secs(1) });
2236
        assert!(sink(Some(70), 30).cancelled(), "the notice is up: handed back whatever was expected");
2237
    }
2238
2239
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
2240
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
2241
    #[test]
2242
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
2243
        use microvm::Sink;
2244
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
2245
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
2246
        let (base, seen) = fake_hub(vec![204]);
2247
        let hub = hub_at(base);
2248
        let mut sink = LogSink {
2249
            hub: &hub,
2250
            job: 1,
2251
            buf: Vec::new(),
2252
            last: quiet,
2253
            gone: false,
2254
            leaving: &NOT_LEAVING,
2255
            started: Instant::now(),
2256
            expected: None,
2257
        };
2258
        assert!(!sink.cancelled());
2259
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
2260
        assert_eq!(n(&seen), 1);
2261
2262
        let (base, seen) = fake_hub(vec![409]);
2263
        let hub = hub_at(base);
2264
        let mut sink = LogSink {
2265
            hub: &hub,
2266
            job: 1,
2267
            buf: Vec::new(),
2268
            last: quiet,
2269
            gone: false,
2270
            leaving: &NOT_LEAVING,
2271
            started: Instant::now(),
2272
            expected: None,
2273
        };
2274
        assert!(sink.cancelled());
2275
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
2276
        sink.flush();
2277
        assert!(sink.cancelled());
2278
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
2279
    }
2280
2281
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
2282
    /// it) costs a job's uploads a wait, not the upload, and costs its log
2283
    /// no lines.
2284
    #[test]
2285
    fn a_job_outlasts_a_hub_restart() {
2286
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
2287
        // A port nobody listens on yet: the hub is restarting.
2288
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2289
        let base = format!("http://127.0.0.1:{port}");
2290
        let hub = hub_at(base.clone());
2291
2292
        // Log lines sent while it is away are kept, and go when it is back.
2293
        let mut sink = LogSink {
2294
            hub: &hub,
2295
            job: 1,
2296
            buf: Vec::new(),
2297
            last: Instant::now(),
2298
            gone: false,
2299
            leaving: &NOT_LEAVING,
2300
            started: Instant::now(),
2301
            expected: None,
2302
        };
2303
        for i in 0..450 {
2304
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
2305
        }
2306
        sink.send();
2307
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
2308
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
2309
        sink.send();
2310
        assert!(sink.buf.is_empty() && !sink.gone);
2311
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
2312
2313
        // An upload waits for the hub to come back, through refused
2314
        // connections and server errors alike.
2315
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2316
        let url = format!("http://127.0.0.1:{port}/x");
2317
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
2318
        let later = std::thread::spawn(move || {
2319
            std::thread::sleep(Duration::from_millis(150));
2320
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
2321
        });
2322
        let quick = [Duration::from_millis(50); 20];
2323
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
2324
        assert_eq!(r.status(), 204);
2325
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
2326
        // Without `connection`, a hub that cannot be reached ends it at once.
2327
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2328
        let url = format!("http://127.0.0.1:{port}/x");
2329
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
2330
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
2331
    }
2332
2333
    /// oxygenflow#50: the first stop drains (the second, not called here,
2334
    /// exits at once).
2335
    #[test]
2336
    fn a_stop_drains_the_runner() {
2337
        assert!(!stopping());
2338
        on_stop(libc::SIGTERM);
2339
        assert!(stopping(), "told to stop, it claims nothing more");
2340
        // An embedded runner starts afresh, and stop() drains it the same way.
2341
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
2342
        stop();
2343
        assert!(stopping());
2344
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
2345
    }
2346
2347
    /// Lines kept for a hub that stays away are bounded, saying how many went.
2348
    #[test]
2349
    fn unsent_log_lines_are_bounded() {
2350
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2351
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
2352
        let mut sink = LogSink {
2353
            hub: &hub,
2354
            job: 1,
2355
            buf: Vec::new(),
2356
            last: Instant::now(),
2357
            gone: false,
2358
            leaving: &NOT_LEAVING,
2359
            started: Instant::now(),
2360
            expected: None,
2361
        };
2362
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
2363
        sink.send();
2364
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
2365
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
2366
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
2367
    }
2368
2369
    #[test]
2370
    fn the_budget_admits_jobs_that_fit() {
2371
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
2372
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
2373
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
2374
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
2375
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
2376
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
2377
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
2378
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
2379
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
2380
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
2381
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
2382
    }
2383
2384
    #[test]
2385
    fn a_claim_never_asks_for_more_memory_than_the_host_has_free() {
2386
        assert_eq!(claim_memory(None, Some(6541)), Some(6541), "idle: the host's free memory, not unlimited");
2387
        assert_eq!(claim_memory(Some(16_384), Some(6541)), Some(6541));
2388
        assert_eq!(claim_memory(Some(4096), Some(6541)), Some(4096), "the budget when it is smaller");
2389
        assert_eq!(claim_memory(None, None), None, "nothing known: no limit, as before");
2390
        assert_eq!(claim_memory(Some(4096), None), Some(4096));
2391
    }
2392
}