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