OXYGENHUB

Coverage / crates/flow/src/runner.rs 46.4%

683 of 1473 lines run, 85 of 343 functions · run #177 on main · run missed

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 {
250×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260×
        write!(f, "HTTP {}: {}", self.code, self.text)
270×
    }
28
}
29
30
impl std::error::Error for Refused {}
31
32
/// Whether `e` is the hub saying the job no longer runs.
333×
fn job_gone(e: &anyhow::Error) -> bool {
343×
    e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409)
353×
}
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
410×
fn cancelled() -> Finish {
420×
    Finish {
430×
        exit_code: None,
440×
        duration_ms: 0,
450×
        receipt: None,
460×
        signed_receipt: None,
470×
        error: Some(CANCELLED.into()),
480×
        peak_disk_mib: None,
490×
        peak_memory_mib: None,
500×
    }
510×
}
52
53
pub struct Hub {
54
    base: String,
55
    token: String,
56
    agent: ureq::Agent,
57
}
58
59
impl Hub {
600×
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
610×
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
620×
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
630×
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
640×
    }
65
66
    /// POST to the hub. A server error (5xx) means nothing was committed,
67
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
68
    /// busy hub costs a job a few seconds, not its log lines or its claim.
698×
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
708×
        self.post_with(path, body, &RETRY_DELAYS)
718×
    }
72
7311×
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
7411×
        let url = format!("{}/api/v1{path}", self.base);
7516×
        let r = retrying(&url, delays, false, || {
7616×
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
7716×
        })?;
786×
        Ok(if r.status() == 204 { None } else { Some(r) })
7911×
    }
80
81
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
82
    /// away for a few minutes, as while a deploy restarts it, so a job's
83
    /// declines and uploads outlast it.
841×
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
851×
        let url = format!("{}/api/v1{path}", self.base);
861×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
871×
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
881×
        })?;
891×
        Ok(if r.status() == 204 { None } else { Some(r) })
901×
    }
91
920×
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
930×
        match self.post("/flow/claim", req)? {
940×
            Some(r) => Ok(Some(r.into_json()?)),
950×
            None => Ok(None),
96
        }
970×
    }
98
99
    /// Hand job `job` back, saying why this runner will not run it.
1000×
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
1010×
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
1020×
        Ok(())
1030×
    }
104
105
    /// Hand back a job this runner holds as it leaves (oxygenflow#76): the
106
    /// hub queues it again ahead of new work for another runner.
1071×
    pub fn release(&self, job: i64, reason: &str) -> Result<()> {
1081×
        self.post_patiently(&format!("/flow/jobs/{job}/release"), &Decline { reason: reason.into() })?;
1091×
        Ok(())
1101×
    }
111
112
    /// Say this runner is there while it claims nothing: its slots are full,
113
    /// or it is draining (oxygenhub#243). Once, no retries: the next comes
114
    /// soon. A hub without the endpoint (404) is not an error.
115
    /// A ten-minute certificate for `identity`, asked with a job's token
116
    /// (keyless, oxygenhub#241), and the identity of the hub's key that
117
    /// issued it, from `/.well-known/oxyhub-key`.
1183×
    pub fn certify(
1193×
        &self,
1203×
        job_token: &str,
1213×
        identity: &oxy_core::identity::Identity,
1223×
        proof: &oxy_core::identity::Signature,
1233×
    ) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> {
124
        use oxy_core::dsse::{base64, base64_decode};
1253×
        let body = serde_json::json!({
1263×
            "identity": base64(&identity.encode()),
1273×
            "proof": base64(&oxy_core::cbor::encode(&proof.to_value())),
1283×
            "scope": "seal",
129
        });
1303×
        let url = format!("{}/api/v1/keyless/certify", self.base);
1313×
        let r = self
1323×
            .agent
1333×
            .post(&url)
1343×
            .set("Authorization", &format!("Bearer {job_token}"))
1353×
            .send_json(body)
1363×
            .map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?;
1373×
        let v: serde_json::Value = r.into_json()?;
1383×
        let cert = base64_decode(v["certificate"].as_str().unwrap_or(""))
1393×
            .context("the hub's answer has no certificate")
1403×
            .and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?;
1413×
        let mut bytes = Vec::new();
1423×
        std::io::Read::read_to_end(
1433×
            &mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(),
1443×
            &mut bytes,
1450×
        )?;
1463×
        let issuer = oxy_core::identity::Identity::decode(&bytes)?;
1473×
        if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() {
1481×
            bail!("the certificate is not signed by the hub's published key");
1492×
        }
1502×
        if cert.cert.key_id() != identity.hash() {
1511×
            bail!("the hub certified another key");
1521×
        }
1531×
        Ok((cert, issuer))
1543×
    }
155
1560×
    pub fn heartbeat(&self) -> Result<()> {
1570×
        match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) {
1580×
            Ok(_) => Ok(()),
1590×
            Err(e) if format!("{e:#}").contains("404") => Ok(()),
1600×
            Err(e) => Err(e),
161
        }
1620×
    }
163
1648×
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
1658×
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
1665×
        Ok(())
1678×
    }
168
169
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
170
    /// a blob of the job's repository and answers with its ObjectRef.
1710×
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
1720×
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
1730×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
1740×
            self.agent
1750×
                .post(&url)
1760×
                .timeout(Duration::from_secs(600))
1770×
                .set("Authorization", &format!("Bearer {}", self.token))
1780×
                .set("Content-Type", "application/octet-stream")
1790×
                .send_bytes(bytes)
1800×
                .map_err(Box::new)
1810×
        })?;
1820×
        let v: serde_json::Value = r.into_json()?;
1830×
        v["blob"].as_str().map(String::from).context("the hub named no blob")
1840×
    }
185
186
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
187
    /// producing job's artifact, which the caller checks against the blob the
188
    /// claim names.
1890×
    pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> {
1900×
        let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base);
1910×
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
1920×
            self.agent
1930×
                .get(&url)
1940×
                .timeout(Duration::from_secs(600))
1950×
                .set("Authorization", &format!("Bearer {}", self.token))
1960×
                .call()
1970×
                .map_err(Box::new)
1980×
        })?;
1990×
        let mut bytes = Vec::new();
2000×
        let mut reader = r.into_reader();
2010×
        std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?;
2020×
        if bytes.len() > MAX_INPUT_BYTES {
2030×
            bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20);
2040×
        }
2050×
        Ok(bytes)
2060×
    }
207
208
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
209
    /// the hub keeps it as a blob and answers with its ObjectRef. The
210
    /// report's manifest follows through [`Hub::artifact`].
2110×
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
2120×
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
2130×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
2140×
            self.agent
2150×
                .post(&url)
2160×
                .timeout(Duration::from_secs(600))
2170×
                .set("Authorization", &format!("Bearer {}", self.token))
2180×
                .set("Content-Type", "application/octet-stream")
2190×
                .set("X-Flow-Report-Path", path)
2200×
                .send_bytes(bytes)
2210×
                .map_err(Box::new)
2220×
        })?;
2230×
        let v: serde_json::Value = r.into_json()?;
2240×
        v["blob"].as_str().map(String::from).context("the hub named no blob")
2250×
    }
226
227
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
228
    /// cannot be reached or fails (5xx), try again after `first_wait`,
229
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
230
    /// already, say) is final. A result lost here leaves the job running
231
    /// on the hub until its time limit.
2322×
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
2332×
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
2342×
        let deadline = std::time::Instant::now() + patience;
2352×
        let mut wait = first_wait;
236
        loop {
2376×
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
2381×
                Ok(_) => return Ok(()),
2395×
                Err(ureq::Error::Status(code, r)) if code < 500 => {
2401×
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
241
                }
2424×
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
2430×
                Err(e) => e.to_string(),
244
            };
2454×
            if std::time::Instant::now() + wait > deadline {
2460×
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
2474×
            }
2484×
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
2494×
            std::thread::sleep(wait);
2504×
            wait = (wait * 2).min(Duration::from_secs(60));
251
        }
2522×
    }
253
}
254
255
/// How long a runner keeps trying to report a job's end.
256
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
257
258
/// Where a job's result waits until the hub has it, so a runner that
259
/// restarts still reports it.
2600×
fn pending_path(home: &Home, job: i64) -> PathBuf {
2610×
    home.root.join("pending").join(format!("{job}.json"))
2620×
}
263
264
/// Report `f`, keeping it on disk until the hub has it.
2650×
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
2660×
    let path = pending_path(home, job);
2670×
    let kept = std::fs::create_dir_all(path.parent().unwrap())
2680×
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
2690×
        .is_ok();
2700×
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
2710×
        Ok(()) => {}
2720×
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
273
    }
2740×
    if kept {
2750×
        let _ = std::fs::remove_file(&path);
2760×
    }
2770×
}
278
279
/// Results a previous run of the runner could not report, to send again.
2800×
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
2810×
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
2820×
        return Vec::new();
283
    };
2840×
    dir.filter_map(|e| {
2850×
        let p = e.ok()?.path();
2860×
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
2870×
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
2880×
        Some((job, f))
2890×
    })
2900×
    .collect()
2910×
}
292
293
/// `flow runner register`: exchange a one-time registration token from
294
/// the hub for this runner's credential, binding the runner's signing key,
295
/// and keep hub, credential and name in runner.toml.
2961×
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
2971×
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
2980×
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
2991×
    }
3001×
    let key = home.runner_key(name)?;
3011×
    let hub = hub.trim_end_matches('/').to_string();
3021×
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
3031×
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
3041×
    let req = Register {
3051×
        token: token.trim().to_string(),
3061×
        name: name.to_string(),
3071×
        identity: hex::encode(key.identity.encode()),
3081×
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
3091×
    };
3101×
    let url = format!("{hub}/api/v1/flow/runners/register");
3111×
    let got: Registered = match agent.post(&url).send_json(&req) {
3121×
        Ok(r) => r.into_json()?,
3130×
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
3140×
        Err(e) => bail!("{url}: {e}"),
315
    };
3161×
    let mut text = format!(
317
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
318
        got.credential, got.name
319
    );
3201×
    if let Some(ca) = &ca {
3210×
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
3221×
    }
3231×
    if !labels.is_empty() {
3241×
        text.push_str(&format!("labels = {labels:?}\n"));
3251×
    }
3261×
    let path = home.root.join(crate::home::RUNNER_TOML);
327
    {
328
        use std::io::Write;
329
        use std::os::unix::fs::OpenOptionsExt;
3301×
        let tmp = path.with_extension("toml.tmp");
3311×
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
3321×
        f.write_all(text.as_bytes())?;
3331×
        f.sync_all()?;
3341×
        std::fs::rename(&tmp, &path)?;
335
    }
3361×
    Ok(got)
3371×
}
338
339
/// Waits before trying a hub request again after a server error.
340
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
341
342
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
343
/// all, longer than a hub takes to restart.
344
/// Check an input's bytes against the blob the producing job's receipt
345
/// names, then write them at its path under `tree` (the workspace's `/src`).
3466×
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> {
3476×
    let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy"))
348
    else {
3494×
        bail!("path {:?} is not a clean path under /src", i.path);
350
    };
3512×
    let blob = Repo::blob_ref(bytes);
3522×
    if blob.to_hex() != i.blob {
3531×
        bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]);
3541×
    }
3551×
    let dest = tree.join(rel);
3561×
    if let Some(dir) = dest.parent() {
3571×
        std::fs::create_dir_all(dir)?;
3580×
    }
3591×
    std::fs::write(&dest, bytes)?;
3601×
    Ok(blob.0)
3616×
}
362
363
/// The largest input a job takes: the hub's artifact limit.
364
pub const MAX_INPUT_BYTES: usize = 512 << 20;
365
366
const PATIENT_DELAYS: [Duration; 15] = {
367
    let mut d = [Duration::from_secs(15); 15];
368
    d[0] = Duration::from_secs(1);
369
    d[1] = Duration::from_secs(2);
370
    d[2] = Duration::from_secs(4);
371
    d[3] = Duration::from_secs(8);
372
    d
373
};
374
375
/// Send a request until the hub answers, waiting `delays` in turn after a
376
/// server error (5xx: nothing was committed) and, when `connection`, after
377
/// the hub could not be reached at all. A refusal (4xx) is final, as a
378
/// [`Refused`] the caller can look at.
37914×
fn retrying(
38014×
    url: &str,
38114×
    delays: &[Duration],
38214×
    connection: bool,
38314×
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
38414×
) -> Result<ureq::Response> {
38514×
    let mut delays = delays.iter();
386
    loop {
38723×
        let why = match send().map_err(|e| *e) {
3888×
            Ok(r) => return Ok(r),
3899×
            Err(ureq::Error::Status(code, r)) if code < 500 => {
3902×
                let text = r.into_string().unwrap_or_default();
3912×
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
392
            }
3937×
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
3943×
            Err(e) if connection => e.to_string(),
3953×
            Err(e) => bail!("{url}: {e}"),
396
        };
39710×
        match delays.next() {
3989×
            Some(d) => std::thread::sleep(*d),
3991×
            None => bail!("{url}: {why}"),
400
        }
401
    }
40214×
}
403
404
/// What the runner may run at once (flow.toml `[runner]`): at most
405
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
406
#[derive(Debug, Default, PartialEq, Eq)]
407
pub struct Budget {
408
    pub max_jobs: usize,
409
    pub memory_mib: Option<u32>,
410
    pub cpus: Option<u32>,
411
}
412
413
/// What is in use now.
414
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
415
pub struct Used {
416
    pub jobs: usize,
417
    pub memory_mib: u32,
418
    pub cpus: u32,
419
}
420
421
impl Budget {
4220×
    pub fn of(cfg: &RunnerConfig) -> Budget {
4230×
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
4240×
    }
425
426
    /// The room to ask the hub for, or `None` when no job more can start.
427
    /// A lone job may use the whole budget even if it asks for more, so a
428
    /// large sandbox is never starved.
4297×
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
4307×
        if used.jobs >= self.max_jobs {
4312×
            return None;
4325×
        }
4335×
        if used.jobs == 0 {
4342×
            return Some((None, None));
4353×
        }
4363×
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
4373×
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
4383×
        if mem == Some(0) || cpus == Some(0) {
4391×
            return None;
4402×
        }
4412×
        Some((mem, cpus))
4427×
    }
443
}
444
445
/// The isolation tiers this machine can honour now (spec §11.1): the
446
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
447
/// flow.toml pins Firecracker; the syscall tier when this binary may use
448
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
449
/// `Err` says why there are none; the reasons for missing tiers come back
450
/// with the ones there are.
4513×
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
4523×
    let mut have = Vec::new();
4533×
    let mut missing = Vec::new();
4543×
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
4553×
    match (kvm, home.config.microvm.is_some()) {
4560×
        (Ok(_), true) => have.push("microvm".to_string()),
4570×
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
4583×
        (Err(e), _) => missing.push(format!(
459
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
460
        )),
461
    }
4623×
    match crate::syscall::available() {
4630×
        Ok(()) => have.push("syscall".to_string()),
4643×
        Err(e) => missing.push(format!("syscall: {e}")),
465
    }
466
    // wasmtime runs on every machine this binary does (oxygenflow#75).
4673×
    have.push("wasm".to_string());
4683×
    if have.is_empty() {
4690×
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
4703×
    }
4713×
    Ok((have, missing))
4723×
}
473
474
/// Fetch `claim`'s image from the hub when this home lacks it
475
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
476
/// leaves it missing, and the preflight declines the job as before.
4770×
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) {
4780×
    let hash = &claim.sandbox.rootfs;
4790×
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
4800×
        return;
4810×
    }
4820×
    let note = |text: String| {
4830×
        eprintln!("job {}: {text}", claim.job_id);
4840×
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
4850×
    };
486
    // One fetch of an image at a time: two jobs wanting it wait for one.
487
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
4880×
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
4890×
    if home.rootfs(hash).is_file() {
4900×
        return;
4910×
    }
4920×
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
4930×
        Ok(c) => c,
4940×
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
495
    };
4960×
    let fetched = match swarm {
4970×
        Some(sw) => {
4980×
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
4990×
            sw.fetch(home, hash)
500
        }
501
        None => {
5020×
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
5030×
            client.fetch(home, hash)
504
        }
505
    };
5060×
    match fetched {
5070×
        Ok(Some(m)) => note(format!(
508
            "image {}: fetched {} chunks, {}{} in {:.1} s",
5090×
            &hash[..12],
510
            m.chunks,
5110×
            crate::images::human(m.bytes),
5120×
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
5130×
            m.elapsed.as_secs_f64()
514
        )),
5150×
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
5160×
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
517
    }
5180×
}
519
520
/// Why this runner cannot run `claim` now (spec §11.5), checked before
521
/// anything is fetched: the tier, the architecture, the image and the
522
/// memory. The hub chose it from what the runner last said, which may be
523
/// out of date.
5240×
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
5250×
    let sb = &claim.sandbox;
5260×
    match sb.runtime {
527
        flow_core::Runtime::Microvm => {
5280×
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
5290×
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
5300×
            }
5310×
            if home.config.microvm.is_none() {
5320×
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
5330×
            }
534
        }
535
        flow_core::Runtime::Syscall => {
5360×
            if let Err(e) = crate::syscall::available() {
5370×
                return Some(format!("the syscall tier cannot run here: {e}"));
5380×
            }
539
        }
540
        // No image and no architecture: only the memory matters.
541
        flow_core::Runtime::Wasm => {
5420×
            return available_memory_mib()
5430×
                .filter(|free| *free < u64::from(sb.memory_mib))
5440×
                .map(|free| format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
545
        }
546
    }
5470×
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
5480×
}
549
550
/// What stops `sb` running on this home's images, on a machine of
551
/// architecture `here` with `free_mib` of memory free.
5528×
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
5538×
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
5541×
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
5557×
    }
5567×
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
5577×
    let image = home.rootfs(&sb.rootfs);
5587×
    if !image.is_file() {
5591×
        return Some(format!("rootfs {short} is not on this runner"));
5606×
    }
5616×
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
5622×
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
5634×
    }
5644×
    if let Err(e) = home.verified(&image, &sb.rootfs) {
5651×
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
5663×
    }
5673×
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
5681×
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
5692×
    }
5702×
    None
5718×
}
572
573
/// `MemAvailable` from /proc/meminfo, in MiB.
5740×
fn available_memory_mib() -> Option<u64> {
5750×
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
5760×
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
5770×
    Some(kib / 1024)
5780×
}
579
5800×
pub fn serve(home: &Home) -> Result<()> {
5810×
    drain_on_stop();
5820×
    serve_until_drained(home)
5830×
}
584
585
/// The runner as a library, as the Android app runs it (oxygenhub-android#8):
586
/// no signal handlers; [`stop`] drains it, and this returns once the jobs
587
/// it holds are done and reported. It may be started again afterwards.
5880×
pub fn serve_embedded(home: &Home) -> Result<()> {
5890×
    STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
5900×
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = None;
5910×
    serve_until_drained(home)
5920×
}
593
594
/// Why the runner is leaving, once [`leave`] says so: the jobs it holds are
595
/// stopped and handed back rather than finished (oxygenflow#76).
596
static LEAVING: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
597
5980×
fn leaving() -> Option<String> {
5990×
    LEAVING.lock().unwrap_or_else(|p| p.into_inner()).clone()
6000×
}
601
602
/// Ask a runner started with [`serve_embedded`] to leave now: claim nothing
603
/// more, stop the jobs it holds and hand them back to the hub, which queues
604
/// them ahead of new work for another runner (oxygenflow#76), then return.
605
/// For a departure with no time to finish: a phone off the allowed network.
6060×
pub fn leave(reason: &str) {
6070×
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = Some(reason.to_string());
6080×
    stop();
6090×
}
610
611
/// Ask a runner started with [`serve_embedded`] to drain: claim nothing
612
/// more, finish and report what it holds, then return (oxygenflow#76's
613
/// drain, for a phone taken off charge or off the allowed network).
6141×
pub fn stop() {
6151×
    STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6161×
}
617
6180×
fn serve_until_drained(home: &Home) -> Result<()> {
6190×
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
620
    // A runner offers only what it can run: a machine without KVM claims
621
    // nothing rather than taking MicroVM jobs and failing them (#36).
6220×
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
6230×
    for why in &missing {
6240×
        eprintln!("flow runner: not offering {why}");
6250×
    }
6260×
    let hub = Hub::new(&cfg)?;
6270×
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
6280×
    let budget = Budget::of(&cfg);
6290×
    let key = home.runner_key(&name)?;
630
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
631
    // peers still runs jobs, fetching images from the hub alone.
6320×
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
6330×
        eprintln!("flow runner: not joining the swarm: {e:#}");
6340×
        None
6350×
    });
6360×
    eprintln!(
637
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
638
        cfg.hub,
639
        budget.max_jobs,
6400×
        runtimes.join(", "),
6410×
        key.identity.hash().to_hex()
642
    );
6430×
    if let Some(addr) = &cfg.metrics {
6440×
        crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone());
6450×
    }
6460×
    let used = std::sync::Mutex::new(Used::default());
6470×
    let mut idle = Duration::from_secs(2);
6480×
    let pending = report_pending(home);
649
    // The jobs held now, told to the hub at each claim so it can end the
650
    // ones this runner lost: a job is held from its claim until its result
651
    // is reported.
6520×
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
6530×
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
6540×
    std::thread::scope(|scope| {
6550×
        for (job, f) in pending {
6560×
            let (hub, held) = (&hub, &held);
6570×
            scope.spawn(move || {
6580×
                eprintln!("job {job}: reporting the result kept from before the restart");
6590×
                report_finish(home, hub, job, &f);
6600×
                held.lock().unwrap().remove(&job);
6610×
            });
662
        }
663
        // The speed (spec §11.1): measured now, and again every 15 minutes
664
        // while no job runs, so a job never shares the CPU with it.
6650×
        let measure = || {
6660×
            let (speed, ms) = crate::facts::bench();
6670×
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
6680×
            speed
6690×
        };
6700×
        let mut speed = measure();
6710×
        let mut benched = Instant::now();
6720×
        let mut said_draining = false;
673
        // When the hub last heard from us (oxygenhub#243): a claim, or a
674
        // heartbeat while there is no room to claim.
6750×
        let mut heard = Instant::now();
6760×
        let beat = |heard: &mut Instant| {
6770×
            if heard.elapsed() >= HUB_HEARTBEAT {
6780×
                if let Err(e) = hub.heartbeat() {
6790×
                    eprintln!("flow runner {name}: heartbeat: {e:#}");
6800×
                }
6810×
                *heard = Instant::now();
6820×
            }
6830×
        };
684
        loop {
685
            // Draining (oxygenflow#50): told to stop, claim nothing more and
686
            // stop once every job held is done and reported.
6870×
            if stopping() {
6880×
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
6890×
                if jobs == 0 && holding == 0 {
6900×
                    eprintln!("flow runner {name}: drained, stopping");
6910×
                    break;
6920×
                }
6930×
                if !said_draining {
6940×
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
6950×
                    said_draining = true;
6960×
                }
6970×
                beat(&mut heard);
6980×
                std::thread::sleep(Duration::from_millis(500));
6990×
                continue;
7000×
            }
7010×
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
7020×
                speed = measure();
7030×
                benched = Instant::now();
7040×
            }
7050×
            let room = budget.room(*used.lock().unwrap());
7060×
            let Some((free_memory_mib, free_cpus)) = room else {
7070×
                beat(&mut heard);
7080×
                std::thread::sleep(Duration::from_millis(500));
7090×
                continue;
710
            };
7110×
            heard = Instant::now();
7120×
            let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times();
7130×
            let req = ClaimRequest {
7140×
                runner: name.clone(),
7150×
                runtimes: runtimes.clone(),
7160×
                free_memory_mib,
7170×
                free_cpus,
7180×
                info: Some(RunnerInfo {
7190×
                    version: env!("OXY_VERSION_LONG").into(),
7200×
                    ..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
7210×
                }),
722
                // Read each time: an image imported while the runner runs counts.
7230×
                images: Some(home.images()),
7240×
                image_archs: Some(home.image_archs()),
7250×
                holding: Some(held.lock().unwrap().iter().copied().collect()),
726
                fetches_images: true,
7270×
                facts: Some(crate::facts::now(home, Some(speed))),
7280×
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
7290×
                caches: Some(cache_times.keys().cloned().collect()),
7300×
                cache_times: Some(cache_times),
731
            };
7320×
            match hub.claim(&req) {
7330×
                Ok(Some(claim)) => {
7340×
                    idle = Duration::from_secs(2);
7350×
                    crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7360×
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
7370×
                    {
7380×
                        let mut u = used.lock().unwrap();
7390×
                        u.jobs += 1;
7400×
                        u.memory_mib += mem;
7410×
                        u.cpus += cpus;
7420×
                    }
7430×
                    held.lock().unwrap().insert(claim.job_id);
7440×
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
7450×
                    scope.spawn(move || {
7460×
                        let id = claim.job_id;
7470×
                        fetch_image(home, hub, cfg, swarm, &claim);
7480×
                        if let Some(why) = preflight(home, &claim) {
7490×
                            eprintln!("job {id}: declined: {why}");
7500×
                            crate::metrics::DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7510×
                            if let Err(e) = hub.decline(id, &why) {
7520×
                                eprintln!("job {id}: decline: {e:#}");
7530×
                            }
7540×
                            held.lock().unwrap().remove(&id);
7550×
                            let mut u = used.lock().unwrap();
7560×
                            u.jobs -= 1;
7570×
                            u.memory_mib -= mem;
7580×
                            u.cpus -= cpus;
7590×
                            return;
7600×
                        }
7610×
                        eprintln!(
762
                            "job {id}: {} {} on {}",
763
                            claim.repo,
764
                            claim.job.name,
7650×
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
766
                        );
7670×
                        crate::metrics::RUNNING.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7680×
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
7690×
                            exit_code: None,
770
                            duration_ms: 0,
7710×
                            receipt: None,
7720×
                            signed_receipt: None,
7730×
                            error: Some(format!("{e:#}")),
7740×
                            peak_disk_mib: None,
7750×
                            peak_memory_mib: None,
7760×
                        });
7770×
                        eprintln!(
778
                            "job {id}: exit {:?}{}",
779
                            finish.exit_code,
7800×
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
781
                        );
7820×
                        crate::metrics::RUNNING.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
7830×
                        let cancelled = finish.error.as_deref() == Some(CANCELLED);
7840×
                        crate::metrics::finished(finish.exit_code, finish.error.as_deref(), finish.duration_ms, cancelled);
7850×
                        match leaving() {
786
                            // Stopped as the runner leaves: another runner takes it.
7870×
                            Some(why) if cancelled => {
7880×
                                eprintln!("job {id}: handed back: {why}");
7890×
                                if let Err(e) = hub.release(id, &why) {
7900×
                                    eprintln!("job {id}: handing back: {e:#}; the hub requeues it once this runner's lease lapses");
7910×
                                }
792
                            }
7930×
                            _ if !cancelled => report_finish(home, hub, id, &finish),
7940×
                            _ => {}
795
                        }
7960×
                        held.lock().unwrap().remove(&id);
7970×
                        let mut u = used.lock().unwrap();
7980×
                        u.jobs -= 1;
7990×
                        u.memory_mib -= mem;
8000×
                        u.cpus -= cpus;
8010×
                    });
802
                }
8030×
                Ok(None) => std::thread::sleep(idle),
8040×
                Err(e) => {
8050×
                    eprintln!("claim: {e:#}");
8060×
                    std::thread::sleep(idle);
8070×
                    idle = (idle * 2).min(Duration::from_secs(60));
8080×
                }
809
            }
810
        }
8110×
        Ok(())
8120×
    })
8130×
}
814
815
/// How many times the runner was told to stop (SIGTERM or SIGINT).
816
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
817
8181×
extern "C" fn on_stop(_: libc::c_int) {
819
    // A second stop means now: only async-signal-safe calls here.
8201×
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
8210×
        unsafe { libc::_exit(143) };
8221×
    }
8231×
}
824
825
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
826
/// so restarting a runner never kills the job it runs: the unit's
827
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
8280×
fn drain_on_stop() {
8290×
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
8300×
    unsafe {
8310×
        libc::signal(libc::SIGTERM, handler);
8320×
        libc::signal(libc::SIGINT, handler);
8330×
    }
8340×
}
835
8363×
fn stopping() -> bool {
8373×
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
8383×
}
839
840
/// Batches records to the hub every second (or every 200 lines). A quiet
841
/// job's sink still asks the hub every few seconds whether the job runs,
842
/// with an empty batch; once the hub answers 409 the job is not wanted.
843
struct LogSink<'a> {
844
    hub: &'a Hub,
845
    job: i64,
846
    buf: Vec<Record>,
847
    last: Instant,
848
    gone: bool,
849
    /// Set when the runner leaves ([`LEAVING`]): the job stops, to be
850
    /// handed back.
851
    leaving: &'a std::sync::Mutex<Option<String>>,
852
}
853
854
/// The most log lines sent at once, as a busy job's sink flushes them.
855
const BATCH: usize = 200;
856
857
/// The most log lines kept while the hub cannot be reached.
858
const MAX_UNSENT: usize = 50_000;
859
860
/// How often a quiet job asks whether it was cancelled.
861
const HEARTBEAT: Duration = Duration::from_secs(5);
862
863
impl microvm::Sink for LogSink<'_> {
8641×
    fn record(&mut self, r: Record) {
8651×
        self.push(r)
8661×
    }
867
8686×
    fn cancelled(&mut self) -> bool {
8696×
        if self.leaving.lock().unwrap_or_else(|p| p.into_inner()).is_some() {
870
            // Handed back as the runner leaves: what the job said so far first.
8711×
            self.flush();
8721×
            return true;
8735×
        }
8745×
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
8752×
            self.send();
8763×
        }
8775×
        self.gone
8786×
    }
879
}
880
881
impl LogSink<'_> {
8821×
    fn push(&mut self, mut r: Record) {
8831×
        if self.gone {
8841×
            return;
8850×
        }
8860×
        if r.text.len() > MAX_RECORD_TEXT {
8870×
            let mut cut = MAX_RECORD_TEXT;
8880×
            while !r.text.is_char_boundary(cut) {
8890×
                cut -= 1;
8900×
            }
8910×
            r.text.truncate(cut);
8920×
        }
8930×
        self.buf.push(r);
8940×
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
8950×
            self.flush();
8960×
        }
8971×
    }
898
8992×
    fn flush(&mut self) {
9002×
        if !self.buf.is_empty() {
9011×
            self.send();
9021×
        }
9032×
        self.last = Instant::now();
9042×
    }
905
906
    /// Send what is buffered, even nothing, in batches of at most
907
    /// `BATCH` lines.
9086×
    fn send(&mut self) {
909
        loop {
9108×
            if self.gone {
9110×
                return;
9128×
            }
9138×
            let n = self.buf.len().min(BATCH);
9148×
            let batch: Vec<Record> = self.buf.drain(..n).collect();
9158×
            match self.hub.log(self.job, batch.clone()) {
9165×
                Ok(()) => {}
9173×
                Err(e) if job_gone(&e) => self.gone = true,
9182×
                Err(e) => {
919
                    // The hub is away (a restart, say): keep the lines for
920
                    // the next batch, up to a bound, so the log loses none
921
                    // (oxygenflow#49).
9222×
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
9232×
                    let mut kept = batch;
9242×
                    kept.append(&mut self.buf);
9252×
                    let over = kept.len().saturating_sub(MAX_UNSENT);
9262×
                    if over > 0 {
9271×
                        kept.drain(..over);
9281×
                        kept.insert(
9291×
                            0,
9301×
                            Record {
9311×
                                t: 0,
9321×
                                stream: "flow".into(),
9331×
                                text: format!("({over} log lines lost while the hub could not be reached)"),
9341×
                            },
9351×
                        );
9361×
                    }
9372×
                    self.buf = kept;
9382×
                    break;
939
                }
940
            }
9416×
            if self.buf.is_empty() {
9424×
                break;
9432×
            }
944
        }
9456×
        self.last = Instant::now();
9466×
    }
947
}
948
949
/// How often a runner with no room to claim tells the hub it is there:
950
/// well inside the hub's 90 s before it shows a runner offline.
951
const HUB_HEARTBEAT: Duration = Duration::from_secs(30);
952
953
/// Held while a job takes its cache disks and images its workspace (see
954
/// `run_claim`): one job at a time on the runner's disk and cache pool.
955
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
956
957
/// Held while the first clone of any repository passes its token through
958
/// the process environment.
959
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
960
961
/// One lock per cached repository: fetches into it, and exports from it,
962
/// never overlap, while jobs of other repositories go on. A slow repository
963
/// (one kept in a bucket, say) once held every job on the runner waiting
964
/// behind a single global lock.
9650×
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
966
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
967
        std::sync::OnceLock::new();
9680×
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
9690×
    locks.entry(name.to_string()).or_default().clone()
9700×
}
971
972
/// Seconds, to one decimal place, for the job's log.
9730×
fn secs(d: std::time::Duration) -> String {
9740×
    format!("{:.1} s", d.as_secs_f64())
9750×
}
976
9770×
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
9780×
    let started = Instant::now();
9790×
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false, leaving: &LEAVING };
9800×
    let note =
9810×
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
9820×
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
9830×
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
984
    // Preparing a workspace: fetches into a cached repository, and exports
985
    // from it, are one job at a time per repository (`repo_lock`); taking
986
    // cache disks and imaging the workspace are one job at a time on the
987
    // runner (`PREPARE`); a first clone, whose token goes through the process
988
    // environment, one at a time (`CLONE`).
9890×
    let main_lock = repo_lock(&claim.repo);
9900×
    let asked = Instant::now();
9910×
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
9920×
    if asked.elapsed().as_secs() >= 2 {
9930×
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
9940×
    }
9950×
    let fetching = Instant::now();
9960×
    let repo = cached_repo(home, cfg, claim)?;
9970×
    let fetched = fetching.elapsed();
9980×
    let mut preparing = None;
9990×
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
10000×
    let _ = std::fs::remove_dir_all(&scratch);
10010×
    std::fs::create_dir_all(scratch.join("tree"))?;
10020×
    let result = (|| -> Result<Finish> {
10030×
        let exporting = Instant::now();
10040×
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
10050×
        main_guard.take();
10060×
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
1007
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
1008
        // workspace and of the scope hash.
10090×
        for sub in &claim.subrepos {
10100×
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
10110×
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
10120×
            }
10130×
            note(
10140×
                &mut sink,
10150×
                format!(
1016
                    "fetching subrepo {} at {} for {}",
1017
                    sub.repo,
10180×
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
1019
                    sub.path
1020
                ),
1021
            );
10220×
            let sub_lock = repo_lock(&sub.repo);
10230×
            let asked = Instant::now();
10240×
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
10250×
            if asked.elapsed().as_secs() >= 2 {
10260×
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
10270×
            }
10280×
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
10290×
            let sub_heads: Vec<ChangeHash> =
10300×
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
10310×
            let dir = scratch.join("tree").join(&sub.path);
10320×
            std::fs::create_dir_all(&dir)?;
10330×
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
10340×
            drop(sub_guard);
10350×
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
1036
        }
10370×
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
1038
        // Inputs (oxygenflow#58): each producing job's artifact, checked
1039
        // against the blob its receipt names, then put at its path. Not part
1040
        // of the scope: they are the run's, not the repository's.
10410×
        let mut input_blobs = Vec::new();
10420×
        for (index, i) in claim.inputs.iter().enumerate() {
10430×
            let what = format!("input {} from {}", i.artifact, i.from);
10440×
            let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?;
10450×
            let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?;
10460×
            note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12]));
10470×
            input_blobs.push(blob);
1048
        }
1049
        // The syscall tier runs on the tree itself; a microVM gets an image.
10500×
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
10510×
        let wasm_tier = claim.sandbox.runtime == flow_core::Runtime::Wasm;
1052
        // The syscall and WASM tiers see the job's files as a directory;
1053
        // the MicroVM tier as an ext4 image.
10540×
        let dir_tier = syscall_tier || wasm_tier;
10550×
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
1056
        // Disk mounts that name a cache start from a kept disk (#28): a
1057
        // microVM takes an image already in place as it is, the syscall tier
1058
        // a directory (#70). Taken before the workspace is imaged: the
1059
        // sources the disk was built from give unchanged files their old
1060
        // times back (#52).
10610×
        let asked = Instant::now();
10620×
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
10630×
        if asked.elapsed().as_secs() >= 2 {
10640×
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
10650×
        }
10660×
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
10670×
        let form = if dir_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
10680×
        let cache_ctx =
10690×
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
10700×
        let mut cached: Vec<(usize, String, String)> = Vec::new();
10710×
        let mut built_from: Option<crate::cache::Sources> = None;
10720×
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
10730×
            let Some(name) = &m.cache else { continue };
10740×
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
10750×
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
10760×
                Some(t) => {
10770×
                    if built_from.is_none() {
10780×
                        built_from = t.sources;
10790×
                    }
10800×
                    t.how
1081
                }
10820×
                None => format!("cache {name}: none kept yet, an empty disk"),
1083
            };
10840×
            note(&mut sink, format!("{} ({})", how, m.target));
1085
            // What the job reads is seen when the disk is pruned (#72).
10860×
            if let Err(e) = crate::cache::forget_reads(form, &crate::cache::disk_path(form, &scratch, i)) {
10870×
                note(&mut sink, format!("cache {name}: {e:#}"));
10880×
            }
10890×
            cached.push((i, key, how));
1090
        }
1091
        // What the job reads or writes on its cache from now on is what it
1092
        // used; the rest may be pruned before the cache is kept (#72).
10930×
        let cache_started = if cached.is_empty() { std::time::SystemTime::now() } else { flow_init::prune::start_clock() };
10940×
        let sources = if cached.is_empty() {
10950×
            crate::cache::Sources::new()
1096
        } else {
10970×
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
10980×
            if built_from.is_some() {
10990×
                note(
11000×
                    &mut sink,
11010×
                    format!(
11020×
                        "cache: {restored} of {} files and directories unchanged since the cached build, their times kept",
11030×
                        sources.len()
11040×
                    ),
11050×
                );
11060×
            }
11070×
            sources
1108
        };
11090×
        if !dir_tier {
11100×
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
11110×
            std::fs::remove_dir_all(scratch.join("tree"))?;
11120×
        }
11130×
        preparing.take();
1114
        // Cancelled while it was being prepared: no sandbox.
11150×
        if microvm::Sink::cancelled(&mut sink) {
11160×
            return Ok(cancelled());
11170×
        }
11180×
        if wasm_tier {
11190×
            note(&mut sink, format!("starting WASM (wasmtime {}): {} MiB", crate::wasm::WASMTIME, claim.sandbox.memory_mib));
11200×
        } else {
11210×
            note(
11220×
                &mut sink,
11230×
                format!(
1124
                    "{}: {} {}, {} MiB, rootfs {}",
11250×
                    if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
1126
                    claim.sandbox.cpus,
11270×
                    if syscall_tier { "CPU" } else { "vCPU" },
1128
                    claim.sandbox.memory_mib,
11290×
                    &claim.sandbox.rootfs[..12]
1130
                ),
1131
            );
1132
        }
11330×
        let timeout = Duration::from_secs(claim.timeout_s);
11340×
        let mut outcome = if wasm_tier {
11350×
            crate::wasm::run(&claim.sandbox, &job, &scratch.join("tree"), &scratch, timeout, &mut sink)?
11360×
        } else if syscall_tier {
11370×
            let group = format!("job-{}", claim.job_id);
11380×
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
1139
        } else {
11400×
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
1141
        };
11420×
        if outcome.cancelled {
11430×
            return Ok(cancelled());
11440×
        }
1145
        // The receipt says what a cached disk started from.
11460×
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
11470×
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
11480×
        for (i, key, how) in &cached {
11490×
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
11500×
        }
1151
        // A passing job's artifacts, read from its disks and uploaded; a
1152
        // declared artifact that cannot be kept fails the job. A failing
1153
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
1154
        // its receipt, and one it cannot keep is only noted.
11550×
        let mut outputs = vec![];
11560×
        let mut output_error = None;
11570×
        let passed = outcome.exit_code == Some(0);
11580×
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
11590×
        for o in wanted {
11600×
            if o.kind == flow_core::OutputKind::Report {
11610×
                let read = if dir_tier {
11620×
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
11630×
                    crate::outputs::read_report_dir(&dir, &inner)
1164
                } else {
11650×
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
11660×
                    crate::outputs::read_report(&image, &inner, &scratch)
1167
                };
11680×
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
11690×
                match kept {
11700×
                    Ok((blob, files, bytes)) => {
11710×
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
11720×
                        let hex = blob.to_hex();
11730×
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
11740×
                        if passed {
11750×
                            outputs.push(blob.0);
11760×
                        }
1177
                    }
11780×
                    Err(e) if passed => {
11790×
                        output_error = Some(format!("report {}: {e:#}", o.name));
11800×
                        break;
1181
                    }
11820×
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
1183
                }
11840×
                continue;
11850×
            }
11860×
            let read = if dir_tier {
11870×
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
11880×
                crate::outputs::read_dir_file(&dir, &inner)
1189
            } else {
11900×
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
11910×
                crate::outputs::read(&image, &inner, &scratch)
1192
            };
11930×
            let kept = read.and_then(|bytes| {
11940×
                let blob = Repo::blob_ref(&bytes);
11950×
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
11960×
                if named != blob.to_hex() {
11970×
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
11980×
                }
11990×
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
12000×
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
12010×
                Ok(blob.0)
12020×
            });
12030×
            match kept {
12040×
                Ok(b) if passed => outputs.push(b),
12050×
                Ok(_) => {}
12060×
                Err(e) if passed => {
12070×
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
12080×
                    break;
1209
                }
12100×
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
1211
            }
1212
        }
12130×
        if let Some(e) = &output_error {
12140×
            note(&mut sink, e.clone());
12150×
        }
12160×
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
12170×
            job_name: claim.job.name.clone(),
12180×
            pipeline_hash: hex32(&claim.pipeline_hash),
12190×
            scope_tree_hash: scope_hash,
12200×
            inputs: input_blobs.clone(),
12210×
            environment: outcome.environment.hash(),
12220×
            outputs,
12230×
            exit_code: code as u32,
12240×
            duration_ms: outcome.duration_ms,
12250×
        });
12260×
        let signed_receipt = match &receipt {
12270×
            Some(r) => {
12280×
                let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?;
12290×
                if cfg.keyless {
12300×
                    match sign_keyless(&repo, &heads, cfg, hub, claim, r) {
12310×
                        Ok(k) => signed = k,
12320×
                        Err(e) => {
12330×
                            note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}"))
1234
                        }
1235
                    }
12360×
                }
12370×
                Some(hex::encode(signed.encode()))
1238
            }
12390×
            None => None,
1240
        };
1241
        // Measured before a cached disk leaves the scratch directory.
12420×
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1243
        // A passing job's cached disks are pruned and kept (#72); a
1244
        // failing job's are thrown away, so nothing it wrote reaches a
1245
        // later job.
12460×
        let kept = passed && output_error.is_none();
12470×
        for (i, key, _) in &cached {
12480×
            let disk = crate::cache::disk_path(form, &scratch, *i);
12490×
            let m = disk_mounts[*i];
12500×
            let prune = crate::cache::Prune {
12510×
                started: cache_started,
12520×
                percent: cfg.cache_prune_percent.unwrap_or(flow_init::prune::DEFAULT_PERCENT),
12530×
                guest: outcome.pruned.iter().find(|(t, _)| *t == m.target).map(|(_, p)| p),
1254
            };
12550×
            match pool.settle(&cache_ctx, m, key, &disk, &sources, kept, &prune) {
12560×
                Ok(Some(line)) => note(&mut sink, line),
12570×
                Ok(None) => {}
12580×
                Err(e) => note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" })),
1259
            }
1260
        }
1261
        Ok(Finish {
12620×
            exit_code: outcome.exit_code,
12630×
            duration_ms: started.elapsed().as_millis() as u64,
12640×
            receipt,
12650×
            signed_receipt,
12660×
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
1267
            // What the job took (oxygenflow#41): its disks are sparse files
1268
            // in the scratch directory, so their blocks now are their peak.
12690×
            peak_disk_mib,
12700×
            peak_memory_mib: outcome.peak_memory_mib,
1271
        })
1272
    })();
12730×
    let _ = workspace::remove_tree(&scratch);
12740×
    sink.flush();
12750×
    result
12760×
}
1277
1278
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
1279
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
1280
/// the claim, so the runner vouches only for what it fetched.
1281
/// Upload a report's files, then its manifest under the report's name, and
1282
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
1283
/// the manifest's blob, the file count and their total size.
12840×
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
12850×
    let mut listed = Vec::with_capacity(files.len());
12860×
    let mut total = 0u64;
12870×
    for (path, bytes) in files {
12880×
        let blob = Repo::blob_ref(bytes).to_hex();
12890×
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
12900×
        if named != blob {
12910×
            bail!("the hub kept {path} as {named}, not {blob}");
12920×
        }
12930×
        total += bytes.len() as u64;
12940×
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
1295
    }
12960×
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
12970×
    let blob = Repo::blob_ref(&manifest);
12980×
    let named = hub.artifact(job, name, &manifest)?;
12990×
    if named != blob.to_hex() {
13000×
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
13010×
    }
13020×
    Ok((blob, files.len(), total))
13030×
}
1304
13050×
fn sign_receipt(
13060×
    repo: &Repo,
13070×
    heads: &[ChangeHash],
13080×
    cfg: &RunnerConfig,
13090×
    key: &RunnerKey,
13100×
    claim: &Claim,
13110×
    receipt: &Receipt,
13120×
) -> Result<oxy_core::receipt::SignedReceipt> {
13130×
    let state = state_of(heads);
13140×
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
13150×
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
13160×
}
1317
1318
/// Sign `receipt` with a fresh key the hub certifies for this job now,
1319
/// just before signing, so the certificate covers the moment the hub
1320
/// accepts it (keyless, oxygenhub#241). The key is dropped with the
1321
/// receipt signed: nothing long-lived signs.
13220×
fn sign_keyless(
13230×
    repo: &Repo,
13240×
    heads: &[ChangeHash],
13250×
    cfg: &RunnerConfig,
13260×
    hub: &Hub,
13270×
    claim: &Claim,
13280×
    receipt: &Receipt,
13290×
) -> Result<oxy_core::receipt::SignedReceipt> {
13300×
    let sk = oxy_core::identity::SecretKey::generate();
13310×
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
13320×
    let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id);
13330×
    let id = sk.identity(&name, "", now);
13340×
    let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?;
13350×
    let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?;
13360×
    let state = state_of(heads);
13370×
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
13380×
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer))
13390×
}
1340
1341
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
13420×
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1343
    use oxy_core::hash::{hash_object, ObjectType};
13440×
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
13450×
}
1346
13470×
fn hex32(s: &str) -> [u8; 32] {
13480×
    let mut out = [0; 32];
13490×
    if let Ok(v) = hex::decode(s) {
13500×
        if v.len() == 32 {
13510×
            out.copy_from_slice(&v);
13520×
        }
13530×
    }
13540×
    out
13550×
}
1356
1357
/// Whether a network error is worth another try: DNS, a refused or reset
1358
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
1359
/// hub restarts (oxygenhub#111).
13608×
fn transient(msg: &str) -> bool {
13618×
    let m = msg.to_lowercase();
13628×
    [
13638×
        "dns failed",
13648×
        "name resolution",
13658×
        "connection refused",
13668×
        "connection reset",
13678×
        "timed out",
13688×
        "timeout",
13698×
        "http 502",
13708×
        "http 503",
13718×
        "http 504",
13728×
        "broken pipe",
13738×
        "unexpected eof",
13748×
    ]
13758×
    .iter()
137647×
    .any(|k| m.contains(k))
13778×
}
1378
1379
/// Run `f` again after a transient network error, waiting `waits` between
1380
/// tries (about a minute in all), and give up on any other error at once.
13813×
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
13823×
    let mut tries = waits.iter();
1383
    loop {
13847×
        match f() {
13851×
            Ok(v) => return Ok(v),
13866×
            Err(e) => match tries.next() {
13875×
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
13882×
                _ => return Err(e),
1389
            },
1390
        }
1391
    }
13923×
}
1393
1394
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
1395
const FETCH_WAITS: [Duration; 5] =
1396
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
1397
1398
/// The runner's copy of a repository, fetched up to date with the job's token.
13990×
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
14000×
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
14010×
}
1402
1403
/// The runner's copy of `repo`, cloned or fetched with `token`.
14040×
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
14050×
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
14060×
    let set_remote = |repo: &mut Repo| -> Result<()> {
14070×
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
14080×
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
14090×
        if let Some(ca) = &cfg.ca {
14100×
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
14110×
        }
14120×
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
14130×
    };
14140×
    if dir.join(".oxy").is_dir() {
14150×
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
14160×
        set_remote(&mut repo)?;
14170×
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
14180×
        return Ok(repo);
14190×
    }
1420
    // First job for this repository: clone with the job's token, which goes
1421
    // through the process environment, so one clone at a time.
14220×
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
14230×
    std::env::set_var("OXY_TOKEN", token);
14240×
    if let Some(ca) = &cfg.ca {
14250×
        std::env::set_var("OXY_CA", ca);
14260×
    }
14270×
    let cloned = with_retries(&FETCH_WAITS, || {
14280×
        let _ = std::fs::remove_dir_all(&dir);
14290×
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
14300×
    });
14310×
    std::env::remove_var("OXY_TOKEN");
14320×
    std::env::remove_var("OXY_CA");
14330×
    let mut repo = cloned.map_err(|e| {
14340×
        let _ = std::fs::remove_dir_all(&dir);
14350×
        anyhow::anyhow!("clone {}: {e}", clone_url)
14360×
    })?;
14370×
    set_remote(&mut repo)?;
14380×
    Ok(repo)
14390×
}
1440
1441
#[cfg(test)]
1442
mod tests {
1443
1444
    #[test]
14451×
    fn an_input_is_placed_only_when_it_is_what_the_receipt_names() {
14461×
        let dir = tempfile::tempdir().unwrap();
14471×
        let bytes = b"the reviewed plan";
14481×
        let blob = Repo::blob_ref(bytes).to_hex();
14491×
        let input = |path: &str, blob: &str| flow_core::api::ArtifactInput {
14506×
            artifact: "tfplan".into(),
14516×
            from: "plan".into(),
14526×
            path: path.into(),
14536×
            blob: blob.into(),
14546×
        };
14551×
        let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap();
14561×
        assert_eq!(hex::encode(got), blob);
14571×
        assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes);
14581×
        let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err();
14591×
        assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}");
14601×
        assert!(!dir.path().join("other").exists(), "nothing written when it does not match");
14614×
        for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] {
14624×
            assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}");
1463
        }
14641×
    }
1465
1466
    use super::*;
1467
1468
    #[test]
14691×
    fn a_runner_names_what_stops_a_sandbox() {
14701×
        let dir = tempfile::tempdir().unwrap();
14711×
        let home = Home::locate(Some(dir.path())).unwrap();
14721×
        let bytes = b"not really ext4";
14731×
        let hash = blake3::hash(bytes).to_hex().to_string();
14741×
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
14751×
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
1476
        }))
14771×
        .unwrap();
14786×
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
14791×
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
14801×
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
14811×
        sb.rootfs = hash.clone();
14821×
        assert_eq!(problem(&sb, Some(4096)), None);
14831×
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
14841×
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
14851×
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
14861×
        sb.arch = Some("aarch64".into());
14871×
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
14881×
        sb.arch = None;
14891×
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
14901×
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
14911×
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
14921×
    }
1493
1494
    #[test]
14951×
    fn a_runner_reads_an_unrecorded_images_architecture_and_declines_another() {
1496
        // An image fetched or imported with no recorded architecture, built
1497
        // for riscv64: the runner reads its shell, records it, and declines
1498
        // instead of running it and failing (spec §11.2, §11.5).
14991×
        let dir = tempfile::tempdir().unwrap();
15001×
        let home = Home::locate(Some(dir.path().join("home").as_path())).unwrap();
15011×
        let tree = dir.path().join("tree");
15021×
        std::fs::create_dir_all(tree.join("bin")).unwrap();
15031×
        let mut elf = vec![0u8; 64];
15041×
        elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
15051×
        elf[18..20].copy_from_slice(&0xF3u16.to_le_bytes());
15061×
        std::fs::write(tree.join("bin/sh"), &elf).unwrap();
15071×
        let img = dir.path().join("rootfs.ext4");
15081×
        let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status();
15091×
        if !made.is_ok_and(|s| s.success()) {
15100×
            eprintln!("mkfs.ext4 not found: skipped");
15110×
            return;
15121×
        }
15131×
        let hash = crate::home::hash_file(&img).unwrap();
15141×
        std::fs::create_dir_all(home.rootfs(&hash).parent().unwrap()).unwrap();
15151×
        std::fs::rename(&img, home.rootfs(&hash)).unwrap();
15161×
        assert!(!home.image_manifest(&hash).exists());
15171×
        let sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
15181×
            "name": "s", "runtime": "syscall", "rootfs": hash, "memory_mib": 64, "cpus": 1, "network": "isolated"
1519
        }))
15201×
        .unwrap();
15211×
        assert_eq!(
15221×
            sandbox_problem(&home, &sb, "x86_64", None).unwrap(),
15231×
            format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])
1524
        );
15251×
        assert_eq!(sandbox_problem(&home, &sb, "riscv64", None), None, "a riscv64 runner takes it");
1526
        // Learned: recorded once, and reported with the runner's claims.
15271×
        let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(home.image_manifest(&hash)).unwrap()).unwrap();
15281×
        assert_eq!(manifest["arch"], "riscv64");
15291×
        assert_eq!(home.image_archs().get(&hash).map(String::as_str), Some("riscv64"));
15301×
    }
1531
1532
    #[test]
15331×
    fn transient_network_errors_are_tried_again_and_others_are_not() {
15341×
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
15351×
        assert!(transient("HTTP 503: the hub is restarting"));
15361×
        assert!(!transient("HTTP 404: no such repository"));
15371×
        let mut n = 0;
15383×
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
15393×
            n += 1;
15403×
            if n < 3 {
15412×
                Err(anyhow::anyhow!("connection refused"))
1542
            } else {
15431×
                Ok(7)
1544
            }
15453×
        });
15461×
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
15471×
        let mut m = 0;
15481×
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
15491×
            m += 1;
15501×
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
15511×
        });
15521×
        assert!(got.is_err());
15531×
        assert_eq!(m, 1, "a refusal is not tried again");
15541×
        let mut k = 0;
15553×
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
15563×
            k += 1;
15573×
            Err(anyhow::anyhow!("timed out"))
15583×
        });
15591×
        assert!(got.is_err());
15601×
        assert_eq!(k, 3, "gives up after the last wait");
15611×
    }
1562
1563
    /// A hub that answers each request with the next of `codes`, counting them.
15648×
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
15658×
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
15668×
    }
1567
1568
    /// A hub at `listener` answering each request with the next of `codes`.
156910×
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1570
        use std::io::{Read, Write};
157110×
        let addr = listener.local_addr().unwrap();
157210×
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
157310×
        let count = seen.clone();
157410×
        std::thread::spawn(move || {
157523×
            for code in codes {
157623×
                let (mut s, _) = listener.accept().unwrap();
1577
                // Read the whole request (headers, then Content-Length bytes
1578
                // of body) before answering: closing early would reset the
1579
                // connection while the client is still sending.
158023×
                let mut req = Vec::new();
158123×
                let mut buf = [0u8; 8192];
1582
                loop {
158326×
                    let n = s.read(&mut buf).unwrap_or(0);
158426×
                    if n == 0 {
15850×
                        break;
158626×
                    }
158726×
                    req.extend_from_slice(&buf[..n]);
158826×
                    let text = String::from_utf8_lossy(&req);
158926×
                    if let Some(end) = text.find("\r\n\r\n") {
159026×
                        let len = text[..end]
159126×
                            .lines()
1592204×
                            .find_map(|l| {
1593204×
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1594204×
                            })
159526×
                            .unwrap_or(0);
159626×
                        if req.len() >= end + 4 + len {
159723×
                            break;
15983×
                        }
15990×
                    }
1600
                }
160123×
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
160223×
                let reason = if code == 204 { "No Content" } else { "Error" };
160323×
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
1604
            }
160510×
        });
160610×
        (format!("http://{addr}"), seen)
160710×
    }
1608
1609
    /// A hub that answers one request with `body` (status 200) and hands
1610
    /// back what it was sent.
16111×
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
1612
        use std::io::{Read, Write};
16131×
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
16141×
        let addr = listener.local_addr().unwrap();
16151×
        let (tx, rx) = std::sync::mpsc::channel();
16161×
        std::thread::spawn(move || {
16171×
            let (mut s, _) = listener.accept().unwrap();
16181×
            let mut req = Vec::new();
16191×
            let mut buf = [0u8; 65536];
1620
            loop {
16211×
                let n = s.read(&mut buf).unwrap_or(0);
16221×
                if n == 0 {
16230×
                    break;
16241×
                }
16251×
                req.extend_from_slice(&buf[..n]);
16261×
                let text = String::from_utf8_lossy(&req);
16271×
                if let Some(end) = text.find("\r\n\r\n") {
16281×
                    let len = text[..end]
16291×
                        .lines()
16307×
                        .find_map(|l| {
16317×
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
16327×
                        })
16331×
                        .unwrap_or(0);
16341×
                    if req.len() >= end + 4 + len {
16351×
                        break;
16360×
                    }
16370×
                }
1638
            }
16391×
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
16401×
            let _ = write!(
16411×
                s,
1642
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
16431×
                body.len()
1644
            );
16451×
        });
16461×
        (format!("http://{addr}"), rx)
16471×
    }
1648
1649
    #[test]
16501×
    fn registering_binds_the_key_and_keeps_the_credential_private() {
1651
        use std::os::unix::fs::PermissionsExt;
16521×
        let dir = tempfile::tempdir().unwrap();
16531×
        let home = Home::locate(Some(dir.path())).unwrap();
16541×
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
16551×
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
16561×
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
16571×
        let req = sent.recv().unwrap();
16581×
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
16591×
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
16601×
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
16611×
        let key = home.runner_key("plex").unwrap();
16621×
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
16631×
        let path = dir.path().join(crate::home::RUNNER_TOML);
16641×
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1665
        // The runner then starts from runner.toml.
16661×
        let again = Home::locate(Some(dir.path())).unwrap();
16671×
        let cfg = again.config.runner.unwrap();
16681×
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
16691×
        assert_eq!(cfg.labels, ["gpu"]);
16701×
    }
1671
1672
    /// A hub that answers certify with a certificate `sign_as` signs for
1673
    /// `cert_key`, and publishes `published` as its key: two requests.
16743×
    fn fake_certify_hub(
16753×
        sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey),
16763×
        cert_key: oxy_core::identity::Identity,
16773×
        published: oxy_core::identity::Identity,
16783×
    ) -> String {
1679
        use std::io::{Read, Write};
16803×
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
16813×
        let addr = listener.local_addr().unwrap();
16823×
        std::thread::spawn(move || {
16833×
            for _ in 0..2 {
16846×
                let Ok((mut s, _)) = listener.accept() else { return };
16856×
                let mut req = Vec::new();
16866×
                let mut buf = [0u8; 65536];
1687
                loop {
16887×
                    let n = s.read(&mut buf).unwrap_or(0);
16897×
                    if n == 0 {
16900×
                        break;
16917×
                    }
16927×
                    req.extend_from_slice(&buf[..n]);
16937×
                    let text = String::from_utf8_lossy(&req);
16947×
                    if let Some(end) = text.find("\r\n\r\n") {
16957×
                        let len = text[..end]
16967×
                            .lines()
169747×
                            .find_map(|l| {
169847×
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
169947×
                            })
17007×
                            .unwrap_or(0);
17017×
                        if req.len() >= end + 4 + len {
17026×
                            break;
17031×
                        }
17040×
                    }
1705
                }
17066×
                let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string();
17076×
                let body: Vec<u8> = if line.contains("/keyless/certify") {
17083×
                    let cert = oxy_core::cert::SignedCertificate::sign(
17093×
                        oxy_core::cert::Certificate {
17103×
                            key: cert_key.clone(),
17113×
                            issuer: sign_as.0.hash(),
17123×
                            subject: "repo:andy/demo:bookmark:main".into(),
17133×
                            claims: vec![],
17143×
                            scope: oxy_core::identity::Scope::SEAL,
17153×
                            not_before: 1,
17163×
                            expires: 600_001,
17173×
                            grant: None,
17183×
                        },
17193×
                        &sign_as.0,
17203×
                        &sign_as.1,
1721
                    );
17223×
                    serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes()
1723
                } else {
17243×
                    published.encode()
1725
                };
17266×
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
17276×
                let _ = s.write_all(&body);
1728
            }
17293×
        });
17303×
        format!("http://{addr}")
17313×
    }
1732
1733
    /// keyless (oxygenhub#241): the runner takes a certificate only when it
1734
    /// is for its fresh key and signed by the key the hub publishes.
1735
    #[test]
17361×
    fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() {
1737
        use oxy_core::identity::SecretKey;
17381×
        let hub_sk = SecretKey::generate();
17391×
        let hub_id = hub_sk.identity("hub", "", 1);
17401×
        let sk = SecretKey::generate();
17411×
        let id = sk.identity("earth-1 job 7", "", 1);
17421×
        let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap();
1743
17441×
        let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone());
17451×
        let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap();
17461×
        assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash()));
1747
17481×
        let mallory_sk = SecretKey::generate();
17491×
        let mallory = mallory_sk.identity("mallory", "", 1);
17501×
        let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone());
17511×
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
17521×
        assert!(e.contains("not signed by the hub's published key"), "{e}");
1753
17541×
        let other = SecretKey::generate().identity("other", "", 1);
17551×
        let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id);
17561×
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
17571×
        assert!(e.contains("another key"), "{e}");
17581×
    }
1759
176013×
    fn hub_at(base: String) -> Hub {
176113×
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
176213×
    }
1763
1764
    /// oxygenhub#71: a job's result is offered until the hub takes it,
1765
    /// through server errors, and a refusal ends the trying at once.
1766
    #[test]
17671×
    fn a_result_is_reported_until_the_hub_takes_it() {
17681×
        let f = Finish {
17691×
            exit_code: Some(0),
17701×
            duration_ms: 1,
17711×
            receipt: None,
17721×
            signed_receipt: None,
17731×
            error: None,
17741×
            peak_disk_mib: None,
17751×
            peak_memory_mib: None,
17761×
        };
17771×
        let quick = Duration::from_millis(10);
17781×
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
17791×
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
17801×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
17811×
        let (base, seen) = fake_hub(vec![409]);
17821×
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
17831×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
17841×
    }
1785
1786
    #[test]
17871×
    fn server_errors_are_tried_again_and_client_errors_are_not() {
17881×
        let quick = [Duration::from_millis(10); 3];
17891×
        let (base, seen) = fake_hub(vec![500, 503, 204]);
17901×
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
17911×
        assert!(r.is_none());
17921×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
1793
17941×
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
17951×
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
17961×
        assert!(e.contains("HTTP 500"), "{e}");
17971×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
1798
17991×
        let (base, seen) = fake_hub(vec![401]);
18001×
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
18011×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
18021×
    }
1803
1804
    static NOT_LEAVING: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
1805
1806
    /// oxygenflow#76: once the runner leaves, a job's sink says it is
1807
    /// cancelled, having sent what the job said so far, and the job is
1808
    /// handed back to the hub.
1809
    #[test]
18101×
    fn a_leaving_runner_stops_its_job_and_hands_it_back() {
1811
        use microvm::Sink;
18121×
        let leaving = std::sync::Mutex::new(None);
18131×
        let (base, seen) = fake_hub(vec![204, 204]);
18141×
        let hub = hub_at(base);
18151×
        let mut sink = LogSink { hub: &hub, job: 7, buf: Vec::new(), last: Instant::now(), gone: false, leaving: &leaving };
18161×
        sink.buf.push(Record { t: 1, stream: "out".into(), text: "step 1".into() });
18171×
        assert!(!sink.cancelled());
18181×
        *leaving.lock().unwrap() = Some("off Wi-Fi".into());
18191×
        assert!(sink.cancelled());
18201×
        assert!(sink.buf.is_empty(), "flushed before the job stops");
18211×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
18221×
        hub.release(7, "off Wi-Fi").unwrap();
18231×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
18241×
    }
1825
1826
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
1827
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
1828
    #[test]
18291×
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
1830
        use microvm::Sink;
18312×
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
18321×
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
18331×
        let (base, seen) = fake_hub(vec![204]);
18341×
        let hub = hub_at(base);
18351×
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false, leaving: &NOT_LEAVING };
18361×
        assert!(!sink.cancelled());
18371×
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
18381×
        assert_eq!(n(&seen), 1);
1839
18401×
        let (base, seen) = fake_hub(vec![409]);
18411×
        let hub = hub_at(base);
18421×
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false, leaving: &NOT_LEAVING };
18431×
        assert!(sink.cancelled());
18441×
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
18451×
        sink.flush();
18461×
        assert!(sink.cancelled());
18471×
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
18481×
    }
1849
1850
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
1851
    /// it) costs a job's uploads a wait, not the upload, and costs its log
1852
    /// no lines.
1853
    #[test]
18541×
    fn a_job_outlasts_a_hub_restart() {
18552×
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1856
        // A port nobody listens on yet: the hub is restarting.
18571×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
18581×
        let base = format!("http://127.0.0.1:{port}");
18591×
        let hub = hub_at(base.clone());
1860
1861
        // Log lines sent while it is away are kept, and go when it is back.
18621×
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false, leaving: &NOT_LEAVING };
1863450×
        for i in 0..450 {
1864450×
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
1865450×
        }
18661×
        sink.send();
18671×
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
18681×
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
18691×
        sink.send();
18701×
        assert!(sink.buf.is_empty() && !sink.gone);
18711×
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
1872
1873
        // An upload waits for the hub to come back, through refused
1874
        // connections and server errors alike.
18751×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
18761×
        let url = format!("http://127.0.0.1:{port}/x");
18771×
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
18781×
        let later = std::thread::spawn(move || {
18791×
            std::thread::sleep(Duration::from_millis(150));
18801×
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
18811×
        });
18821×
        let quick = [Duration::from_millis(50); 20];
18835×
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
18841×
        assert_eq!(r.status(), 204);
18851×
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
1886
        // Without `connection`, a hub that cannot be reached ends it at once.
18871×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
18881×
        let url = format!("http://127.0.0.1:{port}/x");
18891×
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
18901×
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
18911×
    }
1892
1893
    /// oxygenflow#50: the first stop drains (the second, not called here,
1894
    /// exits at once).
1895
    #[test]
18961×
    fn a_stop_drains_the_runner() {
18971×
        assert!(!stopping());
18981×
        on_stop(libc::SIGTERM);
18991×
        assert!(stopping(), "told to stop, it claims nothing more");
1900
        // An embedded runner starts afresh, and stop() drains it the same way.
19011×
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
19021×
        stop();
19031×
        assert!(stopping());
19041×
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
19051×
    }
1906
1907
    /// Lines kept for a hub that stays away are bounded, saying how many went.
1908
    #[test]
19091×
    fn unsent_log_lines_are_bounded() {
19101×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
19111×
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
19121×
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false, leaving: &NOT_LEAVING };
191350010×
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
19141×
        sink.send();
19151×
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
19161×
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
19171×
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
19181×
    }
1919
1920
    #[test]
19211×
    fn the_budget_admits_jobs_that_fit() {
19221×
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
19237×
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
19241×
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
19251×
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
19261×
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
19271×
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
19281×
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
19291×
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
19301×
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
19311×
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
19321×
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
19331×
    }
1934
}