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