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