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