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