OXYGENHUB
# 9ff1fb9f2865 oxygenflow / crates / flow / src / runner.rs
79.7 KiB · Rust · last changed Prune build caches before they are kept 16 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
    /// Say this runner is there while it claims nothing: its slots are full,
106
    /// or it is draining (oxygenhub#243). Once, no retries: the next comes
107
    /// soon. A hub without the endpoint (404) is not an error.
108
    /// A ten-minute certificate for `identity`, asked with a job's token
109
    /// (keyless, oxygenhub#241), and the identity of the hub's key that
110
    /// issued it, from `/.well-known/oxyhub-key`.
111
    pub fn certify(
112
        &self,
113
        job_token: &str,
114
        identity: &oxy_core::identity::Identity,
115
        proof: &oxy_core::identity::Signature,
116
    ) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> {
117
        use oxy_core::dsse::{base64, base64_decode};
118
        let body = serde_json::json!({
119
            "identity": base64(&identity.encode()),
120
            "proof": base64(&oxy_core::cbor::encode(&proof.to_value())),
121
            "scope": "seal",
122
        });
123
        let url = format!("{}/api/v1/keyless/certify", self.base);
124
        let r = self
125
            .agent
126
            .post(&url)
127
            .set("Authorization", &format!("Bearer {job_token}"))
128
            .send_json(body)
129
            .map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?;
130
        let v: serde_json::Value = r.into_json()?;
131
        let cert = base64_decode(v["certificate"].as_str().unwrap_or(""))
132
            .context("the hub's answer has no certificate")
133
            .and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?;
134
        let mut bytes = Vec::new();
135
        std::io::Read::read_to_end(
136
            &mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(),
137
            &mut bytes,
138
        )?;
139
        let issuer = oxy_core::identity::Identity::decode(&bytes)?;
140
        if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() {
141
            bail!("the certificate is not signed by the hub's published key");
142
        }
143
        if cert.cert.key_id() != identity.hash() {
144
            bail!("the hub certified another key");
145
        }
146
        Ok((cert, issuer))
147
    }
148
149
    pub fn heartbeat(&self) -> Result<()> {
150
        match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) {
151
            Ok(_) => Ok(()),
152
            Err(e) if format!("{e:#}").contains("404") => Ok(()),
153
            Err(e) => Err(e),
154
        }
155
    }
156
157
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
158
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
159
        Ok(())
160
    }
161
162
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
163
    /// a blob of the job's repository and answers with its ObjectRef.
164
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
165
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
166
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
167
            self.agent
168
                .post(&url)
169
                .timeout(Duration::from_secs(600))
170
                .set("Authorization", &format!("Bearer {}", self.token))
171
                .set("Content-Type", "application/octet-stream")
172
                .send_bytes(bytes)
173
                .map_err(Box::new)
174
        })?;
175
        let v: serde_json::Value = r.into_json()?;
176
        v["blob"].as_str().map(String::from).context("the hub named no blob")
177
    }
178
179
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
180
    /// producing job's artifact, which the caller checks against the blob the
181
    /// claim names.
182
    pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> {
183
        let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base);
184
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
185
            self.agent
186
                .get(&url)
187
                .timeout(Duration::from_secs(600))
188
                .set("Authorization", &format!("Bearer {}", self.token))
189
                .call()
190
                .map_err(Box::new)
191
        })?;
192
        let mut bytes = Vec::new();
193
        let mut reader = r.into_reader();
194
        std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?;
195
        if bytes.len() > MAX_INPUT_BYTES {
196
            bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20);
197
        }
198
        Ok(bytes)
199
    }
200
201
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
202
    /// the hub keeps it as a blob and answers with its ObjectRef. The
203
    /// report's manifest follows through [`Hub::artifact`].
204
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
205
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
206
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
207
            self.agent
208
                .post(&url)
209
                .timeout(Duration::from_secs(600))
210
                .set("Authorization", &format!("Bearer {}", self.token))
211
                .set("Content-Type", "application/octet-stream")
212
                .set("X-Flow-Report-Path", path)
213
                .send_bytes(bytes)
214
                .map_err(Box::new)
215
        })?;
216
        let v: serde_json::Value = r.into_json()?;
217
        v["blob"].as_str().map(String::from).context("the hub named no blob")
218
    }
219
220
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
221
    /// cannot be reached or fails (5xx), try again after `first_wait`,
222
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
223
    /// already, say) is final. A result lost here leaves the job running
224
    /// on the hub until its time limit.
225
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
226
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
227
        let deadline = std::time::Instant::now() + patience;
228
        let mut wait = first_wait;
229
        loop {
230
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
231
                Ok(_) => return Ok(()),
232
                Err(ureq::Error::Status(code, r)) if code < 500 => {
233
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
234
                }
235
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
236
                Err(e) => e.to_string(),
237
            };
238
            if std::time::Instant::now() + wait > deadline {
239
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
240
            }
241
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
242
            std::thread::sleep(wait);
243
            wait = (wait * 2).min(Duration::from_secs(60));
244
        }
245
    }
246
}
247
248
/// How long a runner keeps trying to report a job's end.
249
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
250
251
/// Where a job's result waits until the hub has it, so a runner that
252
/// restarts still reports it.
253
fn pending_path(home: &Home, job: i64) -> PathBuf {
254
    home.root.join("pending").join(format!("{job}.json"))
255
}
256
257
/// Report `f`, keeping it on disk until the hub has it.
258
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
259
    let path = pending_path(home, job);
260
    let kept = std::fs::create_dir_all(path.parent().unwrap())
261
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
262
        .is_ok();
263
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
264
        Ok(()) => {}
265
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
266
    }
267
    if kept {
268
        let _ = std::fs::remove_file(&path);
269
    }
270
}
271
272
/// Results a previous run of the runner could not report, to send again.
273
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
274
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
275
        return Vec::new();
276
    };
277
    dir.filter_map(|e| {
278
        let p = e.ok()?.path();
279
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
280
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
281
        Some((job, f))
282
    })
283
    .collect()
284
}
285
286
/// `flow runner register`: exchange a one-time registration token from
287
/// the hub for this runner's credential, binding the runner's signing key,
288
/// and keep hub, credential and name in runner.toml.
289
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
290
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
291
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
292
    }
293
    let key = home.runner_key(name)?;
294
    let hub = hub.trim_end_matches('/').to_string();
295
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
296
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
297
    let req = Register {
298
        token: token.trim().to_string(),
299
        name: name.to_string(),
300
        identity: hex::encode(key.identity.encode()),
301
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
302
    };
303
    let url = format!("{hub}/api/v1/flow/runners/register");
304
    let got: Registered = match agent.post(&url).send_json(&req) {
305
        Ok(r) => r.into_json()?,
306
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
307
        Err(e) => bail!("{url}: {e}"),
308
    };
309
    let mut text = format!(
310
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
311
        got.credential, got.name
312
    );
313
    if let Some(ca) = &ca {
314
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
315
    }
316
    if !labels.is_empty() {
317
        text.push_str(&format!("labels = {labels:?}\n"));
318
    }
319
    let path = home.root.join(crate::home::RUNNER_TOML);
320
    {
321
        use std::io::Write;
322
        use std::os::unix::fs::OpenOptionsExt;
323
        let tmp = path.with_extension("toml.tmp");
324
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
325
        f.write_all(text.as_bytes())?;
326
        f.sync_all()?;
327
        std::fs::rename(&tmp, &path)?;
328
    }
329
    Ok(got)
330
}
331
332
/// Waits before trying a hub request again after a server error.
333
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
334
335
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
336
/// all, longer than a hub takes to restart.
337
/// Check an input's bytes against the blob the producing job's receipt
338
/// names, then write them at its path under `tree` (the workspace's `/src`).
339
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> {
340
    let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy"))
341
    else {
342
        bail!("path {:?} is not a clean path under /src", i.path);
343
    };
344
    let blob = Repo::blob_ref(bytes);
345
    if blob.to_hex() != i.blob {
346
        bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]);
347
    }
348
    let dest = tree.join(rel);
349
    if let Some(dir) = dest.parent() {
350
        std::fs::create_dir_all(dir)?;
351
    }
352
    std::fs::write(&dest, bytes)?;
353
    Ok(blob.0)
354
}
355
356
/// The largest input a job takes: the hub's artifact limit.
357
pub const MAX_INPUT_BYTES: usize = 512 << 20;
358
359
const PATIENT_DELAYS: [Duration; 15] = {
360
    let mut d = [Duration::from_secs(15); 15];
361
    d[0] = Duration::from_secs(1);
362
    d[1] = Duration::from_secs(2);
363
    d[2] = Duration::from_secs(4);
364
    d[3] = Duration::from_secs(8);
365
    d
366
};
367
368
/// Send a request until the hub answers, waiting `delays` in turn after a
369
/// server error (5xx: nothing was committed) and, when `connection`, after
370
/// the hub could not be reached at all. A refusal (4xx) is final, as a
371
/// [`Refused`] the caller can look at.
372
fn retrying(
373
    url: &str,
374
    delays: &[Duration],
375
    connection: bool,
376
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
377
) -> Result<ureq::Response> {
378
    let mut delays = delays.iter();
379
    loop {
380
        let why = match send().map_err(|e| *e) {
381
            Ok(r) => return Ok(r),
382
            Err(ureq::Error::Status(code, r)) if code < 500 => {
383
                let text = r.into_string().unwrap_or_default();
384
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
385
            }
386
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
387
            Err(e) if connection => e.to_string(),
388
            Err(e) => bail!("{url}: {e}"),
389
        };
390
        match delays.next() {
391
            Some(d) => std::thread::sleep(*d),
392
            None => bail!("{url}: {why}"),
393
        }
394
    }
395
}
396
397
/// What the runner may run at once (flow.toml `[runner]`): at most
398
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
399
#[derive(Debug, Default, PartialEq, Eq)]
400
pub struct Budget {
401
    pub max_jobs: usize,
402
    pub memory_mib: Option<u32>,
403
    pub cpus: Option<u32>,
404
}
405
406
/// What is in use now.
407
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
408
pub struct Used {
409
    pub jobs: usize,
410
    pub memory_mib: u32,
411
    pub cpus: u32,
412
}
413
414
impl Budget {
415
    pub fn of(cfg: &RunnerConfig) -> Budget {
416
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
417
    }
418
419
    /// The room to ask the hub for, or `None` when no job more can start.
420
    /// A lone job may use the whole budget even if it asks for more, so a
421
    /// large sandbox is never starved.
422
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
423
        if used.jobs >= self.max_jobs {
424
            return None;
425
        }
426
        if used.jobs == 0 {
427
            return Some((None, None));
428
        }
429
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
430
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
431
        if mem == Some(0) || cpus == Some(0) {
432
            return None;
433
        }
434
        Some((mem, cpus))
435
    }
436
}
437
438
/// The isolation tiers this machine can honour now (spec §11.1): the
439
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
440
/// flow.toml pins Firecracker; the syscall tier when this binary may use
441
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
442
/// `Err` says why there are none; the reasons for missing tiers come back
443
/// with the ones there are.
444
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
445
    let mut have = Vec::new();
446
    let mut missing = Vec::new();
447
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
448
    match (kvm, home.config.microvm.is_some()) {
449
        (Ok(_), true) => have.push("microvm".to_string()),
450
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
451
        (Err(e), _) => missing.push(format!(
452
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
453
        )),
454
    }
455
    match crate::syscall::available() {
456
        Ok(()) => have.push("syscall".to_string()),
457
        Err(e) => missing.push(format!("syscall: {e}")),
458
    }
459
    if have.is_empty() {
460
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
461
    }
462
    Ok((have, missing))
463
}
464
465
/// Fetch `claim`'s image from the hub when this home lacks it
466
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
467
/// leaves it missing, and the preflight declines the job as before.
468
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) {
469
    let hash = &claim.sandbox.rootfs;
470
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
471
        return;
472
    }
473
    let note = |text: String| {
474
        eprintln!("job {}: {text}", claim.job_id);
475
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
476
    };
477
    // One fetch of an image at a time: two jobs wanting it wait for one.
478
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
479
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
480
    if home.rootfs(hash).is_file() {
481
        return;
482
    }
483
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
484
        Ok(c) => c,
485
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
486
    };
487
    let fetched = match swarm {
488
        Some(sw) => {
489
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
490
            sw.fetch(home, hash)
491
        }
492
        None => {
493
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
494
            client.fetch(home, hash)
495
        }
496
    };
497
    match fetched {
498
        Ok(Some(m)) => note(format!(
499
            "image {}: fetched {} chunks, {}{} in {:.1} s",
500
            &hash[..12],
501
            m.chunks,
502
            crate::images::human(m.bytes),
503
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
504
            m.elapsed.as_secs_f64()
505
        )),
506
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
507
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
508
    }
509
}
510
511
/// Why this runner cannot run `claim` now (spec §11.5), checked before
512
/// anything is fetched: the tier, the architecture, the image and the
513
/// memory. The hub chose it from what the runner last said, which may be
514
/// out of date.
515
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
516
    let sb = &claim.sandbox;
517
    match sb.runtime {
518
        flow_core::Runtime::Microvm => {
519
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
520
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
521
            }
522
            if home.config.microvm.is_none() {
523
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
524
            }
525
        }
526
        flow_core::Runtime::Syscall => {
527
            if let Err(e) = crate::syscall::available() {
528
                return Some(format!("the syscall tier cannot run here: {e}"));
529
            }
530
        }
531
        flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()),
532
    }
533
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
534
}
535
536
/// What stops `sb` running on this home's images, on a machine of
537
/// architecture `here` with `free_mib` of memory free.
538
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
539
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
540
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
541
    }
542
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
543
    let image = home.rootfs(&sb.rootfs);
544
    if !image.is_file() {
545
        return Some(format!("rootfs {short} is not on this runner"));
546
    }
547
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
548
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
549
    }
550
    if let Err(e) = home.verified(&image, &sb.rootfs) {
551
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
552
    }
553
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
554
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
555
    }
556
    None
557
}
558
559
/// `MemAvailable` from /proc/meminfo, in MiB.
560
fn available_memory_mib() -> Option<u64> {
561
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
562
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
563
    Some(kib / 1024)
564
}
565
566
pub fn serve(home: &Home) -> Result<()> {
567
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
568
    // A runner offers only what it can run: a machine without KVM claims
569
    // nothing rather than taking MicroVM jobs and failing them (#36).
570
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
571
    for why in &missing {
572
        eprintln!("flow runner: not offering {why}");
573
    }
574
    let hub = Hub::new(&cfg)?;
575
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
576
    let budget = Budget::of(&cfg);
577
    drain_on_stop();
578
    let key = home.runner_key(&name)?;
579
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
580
    // peers still runs jobs, fetching images from the hub alone.
581
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
582
        eprintln!("flow runner: not joining the swarm: {e:#}");
583
        None
584
    });
585
    eprintln!(
586
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
587
        cfg.hub,
588
        budget.max_jobs,
589
        runtimes.join(", "),
590
        key.identity.hash().to_hex()
591
    );
592
    let used = std::sync::Mutex::new(Used::default());
593
    let mut idle = Duration::from_secs(2);
594
    let pending = report_pending(home);
595
    // The jobs held now, told to the hub at each claim so it can end the
596
    // ones this runner lost: a job is held from its claim until its result
597
    // is reported.
598
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
599
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
600
    std::thread::scope(|scope| {
601
        for (job, f) in pending {
602
            let (hub, held) = (&hub, &held);
603
            scope.spawn(move || {
604
                eprintln!("job {job}: reporting the result kept from before the restart");
605
                report_finish(home, hub, job, &f);
606
                held.lock().unwrap().remove(&job);
607
            });
608
        }
609
        // The speed (spec §11.1): measured now, and again every 15 minutes
610
        // while no job runs, so a job never shares the CPU with it.
611
        let measure = || {
612
            let (speed, ms) = crate::facts::bench();
613
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
614
            speed
615
        };
616
        let mut speed = measure();
617
        let mut benched = Instant::now();
618
        let mut said_draining = false;
619
        // When the hub last heard from us (oxygenhub#243): a claim, or a
620
        // heartbeat while there is no room to claim.
621
        let mut heard = Instant::now();
622
        let beat = |heard: &mut Instant| {
623
            if heard.elapsed() >= HUB_HEARTBEAT {
624
                if let Err(e) = hub.heartbeat() {
625
                    eprintln!("flow runner {name}: heartbeat: {e:#}");
626
                }
627
                *heard = Instant::now();
628
            }
629
        };
630
        loop {
631
            // Draining (oxygenflow#50): told to stop, claim nothing more and
632
            // stop once every job held is done and reported.
633
            if stopping() {
634
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
635
                if jobs == 0 && holding == 0 {
636
                    eprintln!("flow runner {name}: drained, stopping");
637
                    break;
638
                }
639
                if !said_draining {
640
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
641
                    said_draining = true;
642
                }
643
                beat(&mut heard);
644
                std::thread::sleep(Duration::from_millis(500));
645
                continue;
646
            }
647
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
648
                speed = measure();
649
                benched = Instant::now();
650
            }
651
            let room = budget.room(*used.lock().unwrap());
652
            let Some((free_memory_mib, free_cpus)) = room else {
653
                beat(&mut heard);
654
                std::thread::sleep(Duration::from_millis(500));
655
                continue;
656
            };
657
            heard = Instant::now();
658
            let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times();
659
            let req = ClaimRequest {
660
                runner: name.clone(),
661
                runtimes: runtimes.clone(),
662
                free_memory_mib,
663
                free_cpus,
664
                info: Some(RunnerInfo {
665
                    version: env!("OXY_VERSION_LONG").into(),
666
                    ..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
667
                }),
668
                // Read each time: an image imported while the runner runs counts.
669
                images: Some(home.images()),
670
                image_archs: Some(home.image_archs()),
671
                holding: Some(held.lock().unwrap().iter().copied().collect()),
672
                fetches_images: true,
673
                facts: Some(crate::facts::now(home, Some(speed))),
674
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
675
                caches: Some(cache_times.keys().cloned().collect()),
676
                cache_times: Some(cache_times),
677
            };
678
            match hub.claim(&req) {
679
                Ok(Some(claim)) => {
680
                    idle = Duration::from_secs(2);
681
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
682
                    {
683
                        let mut u = used.lock().unwrap();
684
                        u.jobs += 1;
685
                        u.memory_mib += mem;
686
                        u.cpus += cpus;
687
                    }
688
                    held.lock().unwrap().insert(claim.job_id);
689
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
690
                    scope.spawn(move || {
691
                        let id = claim.job_id;
692
                        fetch_image(home, hub, cfg, swarm, &claim);
693
                        if let Some(why) = preflight(home, &claim) {
694
                            eprintln!("job {id}: declined: {why}");
695
                            if let Err(e) = hub.decline(id, &why) {
696
                                eprintln!("job {id}: decline: {e:#}");
697
                            }
698
                            held.lock().unwrap().remove(&id);
699
                            let mut u = used.lock().unwrap();
700
                            u.jobs -= 1;
701
                            u.memory_mib -= mem;
702
                            u.cpus -= cpus;
703
                            return;
704
                        }
705
                        eprintln!(
706
                            "job {id}: {} {} on {}",
707
                            claim.repo,
708
                            claim.job.name,
709
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
710
                        );
711
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
712
                            exit_code: None,
713
                            duration_ms: 0,
714
                            receipt: None,
715
                            signed_receipt: None,
716
                            error: Some(format!("{e:#}")),
717
                            peak_disk_mib: None,
718
                            peak_memory_mib: None,
719
                        });
720
                        eprintln!(
721
                            "job {id}: exit {:?}{}",
722
                            finish.exit_code,
723
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
724
                        );
725
                        if finish.error.as_deref() != Some(CANCELLED) {
726
                            report_finish(home, hub, id, &finish);
727
                        }
728
                        held.lock().unwrap().remove(&id);
729
                        let mut u = used.lock().unwrap();
730
                        u.jobs -= 1;
731
                        u.memory_mib -= mem;
732
                        u.cpus -= cpus;
733
                    });
734
                }
735
                Ok(None) => std::thread::sleep(idle),
736
                Err(e) => {
737
                    eprintln!("claim: {e:#}");
738
                    std::thread::sleep(idle);
739
                    idle = (idle * 2).min(Duration::from_secs(60));
740
                }
741
            }
742
        }
743
        Ok(())
744
    })
745
}
746
747
/// How many times the runner was told to stop (SIGTERM or SIGINT).
748
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
749
750
extern "C" fn on_stop(_: libc::c_int) {
751
    // A second stop means now: only async-signal-safe calls here.
752
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
753
        unsafe { libc::_exit(143) };
754
    }
755
}
756
757
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
758
/// so restarting a runner never kills the job it runs: the unit's
759
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
760
fn drain_on_stop() {
761
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
762
    unsafe {
763
        libc::signal(libc::SIGTERM, handler);
764
        libc::signal(libc::SIGINT, handler);
765
    }
766
}
767
768
fn stopping() -> bool {
769
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
770
}
771
772
/// Batches records to the hub every second (or every 200 lines). A quiet
773
/// job's sink still asks the hub every few seconds whether the job runs,
774
/// with an empty batch; once the hub answers 409 the job is not wanted.
775
struct LogSink<'a> {
776
    hub: &'a Hub,
777
    job: i64,
778
    buf: Vec<Record>,
779
    last: Instant,
780
    gone: bool,
781
}
782
783
/// The most log lines sent at once, as a busy job's sink flushes them.
784
const BATCH: usize = 200;
785
786
/// The most log lines kept while the hub cannot be reached.
787
const MAX_UNSENT: usize = 50_000;
788
789
/// How often a quiet job asks whether it was cancelled.
790
const HEARTBEAT: Duration = Duration::from_secs(5);
791
792
impl microvm::Sink for LogSink<'_> {
793
    fn record(&mut self, r: Record) {
794
        self.push(r)
795
    }
796
797
    fn cancelled(&mut self) -> bool {
798
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
799
            self.send();
800
        }
801
        self.gone
802
    }
803
}
804
805
impl LogSink<'_> {
806
    fn push(&mut self, mut r: Record) {
807
        if self.gone {
808
            return;
809
        }
810
        if r.text.len() > MAX_RECORD_TEXT {
811
            let mut cut = MAX_RECORD_TEXT;
812
            while !r.text.is_char_boundary(cut) {
813
                cut -= 1;
814
            }
815
            r.text.truncate(cut);
816
        }
817
        self.buf.push(r);
818
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
819
            self.flush();
820
        }
821
    }
822
823
    fn flush(&mut self) {
824
        if !self.buf.is_empty() {
825
            self.send();
826
        }
827
        self.last = Instant::now();
828
    }
829
830
    /// Send what is buffered, even nothing, in batches of at most
831
    /// `BATCH` lines.
832
    fn send(&mut self) {
833
        loop {
834
            if self.gone {
835
                return;
836
            }
837
            let n = self.buf.len().min(BATCH);
838
            let batch: Vec<Record> = self.buf.drain(..n).collect();
839
            match self.hub.log(self.job, batch.clone()) {
840
                Ok(()) => {}
841
                Err(e) if job_gone(&e) => self.gone = true,
842
                Err(e) => {
843
                    // The hub is away (a restart, say): keep the lines for
844
                    // the next batch, up to a bound, so the log loses none
845
                    // (oxygenflow#49).
846
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
847
                    let mut kept = batch;
848
                    kept.append(&mut self.buf);
849
                    let over = kept.len().saturating_sub(MAX_UNSENT);
850
                    if over > 0 {
851
                        kept.drain(..over);
852
                        kept.insert(
853
                            0,
854
                            Record {
855
                                t: 0,
856
                                stream: "flow".into(),
857
                                text: format!("({over} log lines lost while the hub could not be reached)"),
858
                            },
859
                        );
860
                    }
861
                    self.buf = kept;
862
                    break;
863
                }
864
            }
865
            if self.buf.is_empty() {
866
                break;
867
            }
868
        }
869
        self.last = Instant::now();
870
    }
871
}
872
873
/// How often a runner with no room to claim tells the hub it is there:
874
/// well inside the hub's 90 s before it shows a runner offline.
875
const HUB_HEARTBEAT: Duration = Duration::from_secs(30);
876
877
/// Held while a job takes its cache disks and images its workspace (see
878
/// `run_claim`): one job at a time on the runner's disk and cache pool.
879
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
880
881
/// Held while the first clone of any repository passes its token through
882
/// the process environment.
883
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
884
885
/// One lock per cached repository: fetches into it, and exports from it,
886
/// never overlap, while jobs of other repositories go on. A slow repository
887
/// (one kept in a bucket, say) once held every job on the runner waiting
888
/// behind a single global lock.
889
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
890
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
891
        std::sync::OnceLock::new();
892
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
893
    locks.entry(name.to_string()).or_default().clone()
894
}
895
896
/// Seconds, to one decimal place, for the job's log.
897
fn secs(d: std::time::Duration) -> String {
898
    format!("{:.1} s", d.as_secs_f64())
899
}
900
901
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
902
    let started = Instant::now();
903
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
904
    let note =
905
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
906
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
907
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
908
    // Preparing a workspace: fetches into a cached repository, and exports
909
    // from it, are one job at a time per repository (`repo_lock`); taking
910
    // cache disks and imaging the workspace are one job at a time on the
911
    // runner (`PREPARE`); a first clone, whose token goes through the process
912
    // environment, one at a time (`CLONE`).
913
    let main_lock = repo_lock(&claim.repo);
914
    let asked = Instant::now();
915
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
916
    if asked.elapsed().as_secs() >= 2 {
917
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
918
    }
919
    let fetching = Instant::now();
920
    let repo = cached_repo(home, cfg, claim)?;
921
    let fetched = fetching.elapsed();
922
    let mut preparing = None;
923
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
924
    let _ = std::fs::remove_dir_all(&scratch);
925
    std::fs::create_dir_all(scratch.join("tree"))?;
926
    let result = (|| -> Result<Finish> {
927
        let exporting = Instant::now();
928
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
929
        main_guard.take();
930
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
931
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
932
        // workspace and of the scope hash.
933
        for sub in &claim.subrepos {
934
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
935
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
936
            }
937
            note(
938
                &mut sink,
939
                format!(
940
                    "fetching subrepo {} at {} for {}",
941
                    sub.repo,
942
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
943
                    sub.path
944
                ),
945
            );
946
            let sub_lock = repo_lock(&sub.repo);
947
            let asked = Instant::now();
948
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
949
            if asked.elapsed().as_secs() >= 2 {
950
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
951
            }
952
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
953
            let sub_heads: Vec<ChangeHash> =
954
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
955
            let dir = scratch.join("tree").join(&sub.path);
956
            std::fs::create_dir_all(&dir)?;
957
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
958
            drop(sub_guard);
959
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
960
        }
961
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
962
        // Inputs (oxygenflow#58): each producing job's artifact, checked
963
        // against the blob its receipt names, then put at its path. Not part
964
        // of the scope: they are the run's, not the repository's.
965
        let mut input_blobs = Vec::new();
966
        for (index, i) in claim.inputs.iter().enumerate() {
967
            let what = format!("input {} from {}", i.artifact, i.from);
968
            let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?;
969
            let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?;
970
            note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12]));
971
            input_blobs.push(blob);
972
        }
973
        // The syscall tier runs on the tree itself; a microVM gets an image.
974
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
975
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
976
        // Disk mounts that name a cache start from a kept disk (#28): a
977
        // microVM takes an image already in place as it is, the syscall tier
978
        // a directory (#70). Taken before the workspace is imaged: the
979
        // sources the disk was built from give unchanged files their old
980
        // times back (#52).
981
        let asked = Instant::now();
982
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
983
        if asked.elapsed().as_secs() >= 2 {
984
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
985
        }
986
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
987
        let form = if syscall_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
988
        let cache_ctx =
989
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
990
        let mut cached: Vec<(usize, String, String)> = Vec::new();
991
        let mut built_from: Option<crate::cache::Sources> = None;
992
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
993
            let Some(name) = &m.cache else { continue };
994
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
995
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
996
                Some(t) => {
997
                    if built_from.is_none() {
998
                        built_from = t.sources;
999
                    }
1000
                    t.how
1001
                }
1002
                None => format!("cache {name}: none kept yet, an empty disk"),
1003
            };
1004
            note(&mut sink, format!("{} ({})", how, m.target));
1005
            // What the job reads is seen when the disk is pruned (#72).
1006
            if let Err(e) = crate::cache::forget_reads(form, &crate::cache::disk_path(form, &scratch, i)) {
1007
                note(&mut sink, format!("cache {name}: {e:#}"));
1008
            }
1009
            cached.push((i, key, how));
1010
        }
1011
        // What the job reads or writes on its cache from now on is what it
1012
        // used; the rest may be pruned before the cache is kept (#72).
1013
        let cache_started = if cached.is_empty() { std::time::SystemTime::now() } else { flow_init::prune::start_clock() };
1014
        let sources = if cached.is_empty() {
1015
            crate::cache::Sources::new()
1016
        } else {
1017
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
1018
            if built_from.is_some() {
1019
                note(&mut sink, format!("cache: {restored} of {} files unchanged since the cached build, their times kept", sources.len()));
1020
            }
1021
            sources
1022
        };
1023
        if !syscall_tier {
1024
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
1025
            std::fs::remove_dir_all(scratch.join("tree"))?;
1026
        }
1027
        preparing.take();
1028
        // Cancelled while it was being prepared: no sandbox.
1029
        if microvm::Sink::cancelled(&mut sink) {
1030
            return Ok(cancelled());
1031
        }
1032
        note(
1033
            &mut sink,
1034
            format!(
1035
                "{}: {} {}, {} MiB, rootfs {}",
1036
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
1037
                claim.sandbox.cpus,
1038
                if syscall_tier { "CPU" } else { "vCPU" },
1039
                claim.sandbox.memory_mib,
1040
                &claim.sandbox.rootfs[..12]
1041
            ),
1042
        );
1043
        let timeout = Duration::from_secs(claim.timeout_s);
1044
        let mut outcome = if syscall_tier {
1045
            let group = format!("job-{}", claim.job_id);
1046
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
1047
        } else {
1048
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
1049
        };
1050
        if outcome.cancelled {
1051
            return Ok(cancelled());
1052
        }
1053
        // The receipt says what a cached disk started from.
1054
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
1055
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
1056
        for (i, key, how) in &cached {
1057
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
1058
        }
1059
        // A passing job's artifacts, read from its disks and uploaded; a
1060
        // declared artifact that cannot be kept fails the job. A failing
1061
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
1062
        // its receipt, and one it cannot keep is only noted.
1063
        let mut outputs = vec![];
1064
        let mut output_error = None;
1065
        let passed = outcome.exit_code == Some(0);
1066
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
1067
        for o in wanted {
1068
            if o.kind == flow_core::OutputKind::Report {
1069
                let read = if syscall_tier {
1070
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
1071
                    crate::outputs::read_report_dir(&dir, &inner)
1072
                } else {
1073
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
1074
                    crate::outputs::read_report(&image, &inner, &scratch)
1075
                };
1076
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
1077
                match kept {
1078
                    Ok((blob, files, bytes)) => {
1079
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
1080
                        let hex = blob.to_hex();
1081
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
1082
                        if passed {
1083
                            outputs.push(blob.0);
1084
                        }
1085
                    }
1086
                    Err(e) if passed => {
1087
                        output_error = Some(format!("report {}: {e:#}", o.name));
1088
                        break;
1089
                    }
1090
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
1091
                }
1092
                continue;
1093
            }
1094
            let read = if syscall_tier {
1095
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
1096
                crate::outputs::read_dir_file(&dir, &inner)
1097
            } else {
1098
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
1099
                crate::outputs::read(&image, &inner, &scratch)
1100
            };
1101
            let kept = read.and_then(|bytes| {
1102
                let blob = Repo::blob_ref(&bytes);
1103
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
1104
                if named != blob.to_hex() {
1105
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
1106
                }
1107
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
1108
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
1109
                Ok(blob.0)
1110
            });
1111
            match kept {
1112
                Ok(b) if passed => outputs.push(b),
1113
                Ok(_) => {}
1114
                Err(e) if passed => {
1115
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
1116
                    break;
1117
                }
1118
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
1119
            }
1120
        }
1121
        if let Some(e) = &output_error {
1122
            note(&mut sink, e.clone());
1123
        }
1124
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
1125
            job_name: claim.job.name.clone(),
1126
            pipeline_hash: hex32(&claim.pipeline_hash),
1127
            scope_tree_hash: scope_hash,
1128
            inputs: input_blobs.clone(),
1129
            environment: outcome.environment.hash(),
1130
            outputs,
1131
            exit_code: code as u32,
1132
            duration_ms: outcome.duration_ms,
1133
        });
1134
        let signed_receipt = match &receipt {
1135
            Some(r) => {
1136
                let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?;
1137
                if cfg.keyless {
1138
                    match sign_keyless(&repo, &heads, cfg, hub, claim, r) {
1139
                        Ok(k) => signed = k,
1140
                        Err(e) => {
1141
                            note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}"))
1142
                        }
1143
                    }
1144
                }
1145
                Some(hex::encode(signed.encode()))
1146
            }
1147
            None => None,
1148
        };
1149
        // Measured before a cached disk leaves the scratch directory.
1150
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1151
        // A passing job's cached disks are pruned and kept (#72); a
1152
        // failing job's are thrown away, so nothing it wrote reaches a
1153
        // later job.
1154
        let kept = passed && output_error.is_none();
1155
        for (i, key, _) in &cached {
1156
            let disk = crate::cache::disk_path(form, &scratch, *i);
1157
            let m = disk_mounts[*i];
1158
            let prune = crate::cache::Prune {
1159
                started: cache_started,
1160
                percent: cfg.cache_prune_percent.unwrap_or(flow_init::prune::DEFAULT_PERCENT),
1161
                guest: outcome.pruned.iter().find(|(t, _)| *t == m.target).map(|(_, p)| p),
1162
            };
1163
            match pool.settle(&cache_ctx, m, key, &disk, &sources, kept, &prune) {
1164
                Ok(Some(line)) => note(&mut sink, line),
1165
                Ok(None) => {}
1166
                Err(e) => note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" })),
1167
            }
1168
        }
1169
        Ok(Finish {
1170
            exit_code: outcome.exit_code,
1171
            duration_ms: started.elapsed().as_millis() as u64,
1172
            receipt,
1173
            signed_receipt,
1174
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
1175
            // What the job took (oxygenflow#41): its disks are sparse files
1176
            // in the scratch directory, so their blocks now are their peak.
1177
            peak_disk_mib,
1178
            peak_memory_mib: outcome.peak_memory_mib,
1179
        })
1180
    })();
1181
    let _ = workspace::remove_tree(&scratch);
1182
    sink.flush();
1183
    result
1184
}
1185
1186
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
1187
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
1188
/// the claim, so the runner vouches only for what it fetched.
1189
/// Upload a report's files, then its manifest under the report's name, and
1190
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
1191
/// the manifest's blob, the file count and their total size.
1192
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
1193
    let mut listed = Vec::with_capacity(files.len());
1194
    let mut total = 0u64;
1195
    for (path, bytes) in files {
1196
        let blob = Repo::blob_ref(bytes).to_hex();
1197
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
1198
        if named != blob {
1199
            bail!("the hub kept {path} as {named}, not {blob}");
1200
        }
1201
        total += bytes.len() as u64;
1202
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
1203
    }
1204
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
1205
    let blob = Repo::blob_ref(&manifest);
1206
    let named = hub.artifact(job, name, &manifest)?;
1207
    if named != blob.to_hex() {
1208
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
1209
    }
1210
    Ok((blob, files.len(), total))
1211
}
1212
1213
fn sign_receipt(
1214
    repo: &Repo,
1215
    heads: &[ChangeHash],
1216
    cfg: &RunnerConfig,
1217
    key: &RunnerKey,
1218
    claim: &Claim,
1219
    receipt: &Receipt,
1220
) -> Result<oxy_core::receipt::SignedReceipt> {
1221
    let state = state_of(heads);
1222
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1223
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
1224
}
1225
1226
/// Sign `receipt` with a fresh key the hub certifies for this job now,
1227
/// just before signing, so the certificate covers the moment the hub
1228
/// accepts it (keyless, oxygenhub#241). The key is dropped with the
1229
/// receipt signed: nothing long-lived signs.
1230
fn sign_keyless(
1231
    repo: &Repo,
1232
    heads: &[ChangeHash],
1233
    cfg: &RunnerConfig,
1234
    hub: &Hub,
1235
    claim: &Claim,
1236
    receipt: &Receipt,
1237
) -> Result<oxy_core::receipt::SignedReceipt> {
1238
    let sk = oxy_core::identity::SecretKey::generate();
1239
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
1240
    let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id);
1241
    let id = sk.identity(&name, "", now);
1242
    let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?;
1243
    let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?;
1244
    let state = state_of(heads);
1245
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1246
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer))
1247
}
1248
1249
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
1250
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1251
    use oxy_core::hash::{hash_object, ObjectType};
1252
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
1253
}
1254
1255
fn hex32(s: &str) -> [u8; 32] {
1256
    let mut out = [0; 32];
1257
    if let Ok(v) = hex::decode(s) {
1258
        if v.len() == 32 {
1259
            out.copy_from_slice(&v);
1260
        }
1261
    }
1262
    out
1263
}
1264
1265
/// Whether a network error is worth another try: DNS, a refused or reset
1266
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
1267
/// hub restarts (oxygenhub#111).
1268
fn transient(msg: &str) -> bool {
1269
    let m = msg.to_lowercase();
1270
    [
1271
        "dns failed",
1272
        "name resolution",
1273
        "connection refused",
1274
        "connection reset",
1275
        "timed out",
1276
        "timeout",
1277
        "http 502",
1278
        "http 503",
1279
        "http 504",
1280
        "broken pipe",
1281
        "unexpected eof",
1282
    ]
1283
    .iter()
1284
    .any(|k| m.contains(k))
1285
}
1286
1287
/// Run `f` again after a transient network error, waiting `waits` between
1288
/// tries (about a minute in all), and give up on any other error at once.
1289
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
1290
    let mut tries = waits.iter();
1291
    loop {
1292
        match f() {
1293
            Ok(v) => return Ok(v),
1294
            Err(e) => match tries.next() {
1295
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
1296
                _ => return Err(e),
1297
            },
1298
        }
1299
    }
1300
}
1301
1302
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
1303
const FETCH_WAITS: [Duration; 5] =
1304
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
1305
1306
/// The runner's copy of a repository, fetched up to date with the job's token.
1307
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
1308
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
1309
}
1310
1311
/// The runner's copy of `repo`, cloned or fetched with `token`.
1312
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
1313
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
1314
    let set_remote = |repo: &mut Repo| -> Result<()> {
1315
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
1316
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
1317
        if let Some(ca) = &cfg.ca {
1318
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
1319
        }
1320
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
1321
    };
1322
    if dir.join(".oxy").is_dir() {
1323
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
1324
        set_remote(&mut repo)?;
1325
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
1326
        return Ok(repo);
1327
    }
1328
    // First job for this repository: clone with the job's token, which goes
1329
    // through the process environment, so one clone at a time.
1330
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
1331
    std::env::set_var("OXY_TOKEN", token);
1332
    if let Some(ca) = &cfg.ca {
1333
        std::env::set_var("OXY_CA", ca);
1334
    }
1335
    let cloned = with_retries(&FETCH_WAITS, || {
1336
        let _ = std::fs::remove_dir_all(&dir);
1337
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
1338
    });
1339
    std::env::remove_var("OXY_TOKEN");
1340
    std::env::remove_var("OXY_CA");
1341
    let mut repo = cloned.map_err(|e| {
1342
        let _ = std::fs::remove_dir_all(&dir);
1343
        anyhow::anyhow!("clone {}: {e}", clone_url)
1344
    })?;
1345
    set_remote(&mut repo)?;
1346
    Ok(repo)
1347
}
1348
1349
#[cfg(test)]
1350
mod tests {
1351
1352
    #[test]
1353
    fn an_input_is_placed_only_when_it_is_what_the_receipt_names() {
1354
        let dir = tempfile::tempdir().unwrap();
1355
        let bytes = b"the reviewed plan";
1356
        let blob = Repo::blob_ref(bytes).to_hex();
1357
        let input = |path: &str, blob: &str| flow_core::api::ArtifactInput {
1358
            artifact: "tfplan".into(),
1359
            from: "plan".into(),
1360
            path: path.into(),
1361
            blob: blob.into(),
1362
        };
1363
        let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap();
1364
        assert_eq!(hex::encode(got), blob);
1365
        assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes);
1366
        let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err();
1367
        assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}");
1368
        assert!(!dir.path().join("other").exists(), "nothing written when it does not match");
1369
        for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] {
1370
            assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}");
1371
        }
1372
    }
1373
1374
    use super::*;
1375
1376
    #[test]
1377
    fn a_runner_names_what_stops_a_sandbox() {
1378
        let dir = tempfile::tempdir().unwrap();
1379
        let home = Home::locate(Some(dir.path())).unwrap();
1380
        let bytes = b"not really ext4";
1381
        let hash = blake3::hash(bytes).to_hex().to_string();
1382
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
1383
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
1384
        }))
1385
        .unwrap();
1386
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
1387
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
1388
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
1389
        sb.rootfs = hash.clone();
1390
        assert_eq!(problem(&sb, Some(4096)), None);
1391
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
1392
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
1393
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
1394
        sb.arch = Some("aarch64".into());
1395
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
1396
        sb.arch = None;
1397
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
1398
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
1399
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
1400
    }
1401
1402
    #[test]
1403
    fn transient_network_errors_are_tried_again_and_others_are_not() {
1404
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
1405
        assert!(transient("HTTP 503: the hub is restarting"));
1406
        assert!(!transient("HTTP 404: no such repository"));
1407
        let mut n = 0;
1408
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
1409
            n += 1;
1410
            if n < 3 {
1411
                Err(anyhow::anyhow!("connection refused"))
1412
            } else {
1413
                Ok(7)
1414
            }
1415
        });
1416
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
1417
        let mut m = 0;
1418
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
1419
            m += 1;
1420
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
1421
        });
1422
        assert!(got.is_err());
1423
        assert_eq!(m, 1, "a refusal is not tried again");
1424
        let mut k = 0;
1425
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
1426
            k += 1;
1427
            Err(anyhow::anyhow!("timed out"))
1428
        });
1429
        assert!(got.is_err());
1430
        assert_eq!(k, 3, "gives up after the last wait");
1431
    }
1432
1433
    /// A hub that answers each request with the next of `codes`, counting them.
1434
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1435
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
1436
    }
1437
1438
    /// A hub at `listener` answering each request with the next of `codes`.
1439
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1440
        use std::io::{Read, Write};
1441
        let addr = listener.local_addr().unwrap();
1442
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1443
        let count = seen.clone();
1444
        std::thread::spawn(move || {
1445
            for code in codes {
1446
                let (mut s, _) = listener.accept().unwrap();
1447
                let mut buf = [0u8; 8192];
1448
                let _ = s.read(&mut buf);
1449
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1450
                let reason = if code == 204 { "No Content" } else { "Error" };
1451
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
1452
            }
1453
        });
1454
        (format!("http://{addr}"), seen)
1455
    }
1456
1457
    /// A hub that answers one request with `body` (status 200) and hands
1458
    /// back what it was sent.
1459
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
1460
        use std::io::{Read, Write};
1461
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1462
        let addr = listener.local_addr().unwrap();
1463
        let (tx, rx) = std::sync::mpsc::channel();
1464
        std::thread::spawn(move || {
1465
            let (mut s, _) = listener.accept().unwrap();
1466
            let mut req = Vec::new();
1467
            let mut buf = [0u8; 65536];
1468
            loop {
1469
                let n = s.read(&mut buf).unwrap_or(0);
1470
                if n == 0 {
1471
                    break;
1472
                }
1473
                req.extend_from_slice(&buf[..n]);
1474
                let text = String::from_utf8_lossy(&req);
1475
                if let Some(end) = text.find("\r\n\r\n") {
1476
                    let len = text[..end]
1477
                        .lines()
1478
                        .find_map(|l| {
1479
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1480
                        })
1481
                        .unwrap_or(0);
1482
                    if req.len() >= end + 4 + len {
1483
                        break;
1484
                    }
1485
                }
1486
            }
1487
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
1488
            let _ = write!(
1489
                s,
1490
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1491
                body.len()
1492
            );
1493
        });
1494
        (format!("http://{addr}"), rx)
1495
    }
1496
1497
    #[test]
1498
    fn registering_binds_the_key_and_keeps_the_credential_private() {
1499
        use std::os::unix::fs::PermissionsExt;
1500
        let dir = tempfile::tempdir().unwrap();
1501
        let home = Home::locate(Some(dir.path())).unwrap();
1502
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
1503
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
1504
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
1505
        let req = sent.recv().unwrap();
1506
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
1507
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
1508
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
1509
        let key = home.runner_key("plex").unwrap();
1510
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
1511
        let path = dir.path().join(crate::home::RUNNER_TOML);
1512
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1513
        // The runner then starts from runner.toml.
1514
        let again = Home::locate(Some(dir.path())).unwrap();
1515
        let cfg = again.config.runner.unwrap();
1516
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
1517
        assert_eq!(cfg.labels, ["gpu"]);
1518
    }
1519
1520
    /// A hub that answers certify with a certificate `sign_as` signs for
1521
    /// `cert_key`, and publishes `published` as its key: two requests.
1522
    fn fake_certify_hub(
1523
        sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey),
1524
        cert_key: oxy_core::identity::Identity,
1525
        published: oxy_core::identity::Identity,
1526
    ) -> String {
1527
        use std::io::{Read, Write};
1528
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1529
        let addr = listener.local_addr().unwrap();
1530
        std::thread::spawn(move || {
1531
            for _ in 0..2 {
1532
                let Ok((mut s, _)) = listener.accept() else { return };
1533
                let mut req = Vec::new();
1534
                let mut buf = [0u8; 65536];
1535
                loop {
1536
                    let n = s.read(&mut buf).unwrap_or(0);
1537
                    if n == 0 {
1538
                        break;
1539
                    }
1540
                    req.extend_from_slice(&buf[..n]);
1541
                    let text = String::from_utf8_lossy(&req);
1542
                    if let Some(end) = text.find("\r\n\r\n") {
1543
                        let len = text[..end]
1544
                            .lines()
1545
                            .find_map(|l| {
1546
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
1547
                            })
1548
                            .unwrap_or(0);
1549
                        if req.len() >= end + 4 + len {
1550
                            break;
1551
                        }
1552
                    }
1553
                }
1554
                let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string();
1555
                let body: Vec<u8> = if line.contains("/keyless/certify") {
1556
                    let cert = oxy_core::cert::SignedCertificate::sign(
1557
                        oxy_core::cert::Certificate {
1558
                            key: cert_key.clone(),
1559
                            issuer: sign_as.0.hash(),
1560
                            subject: "repo:andy/demo:bookmark:main".into(),
1561
                            claims: vec![],
1562
                            scope: oxy_core::identity::Scope::SEAL,
1563
                            not_before: 1,
1564
                            expires: 600_001,
1565
                            grant: None,
1566
                        },
1567
                        &sign_as.0,
1568
                        &sign_as.1,
1569
                    );
1570
                    serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes()
1571
                } else {
1572
                    published.encode()
1573
                };
1574
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
1575
                let _ = s.write_all(&body);
1576
            }
1577
        });
1578
        format!("http://{addr}")
1579
    }
1580
1581
    /// keyless (oxygenhub#241): the runner takes a certificate only when it
1582
    /// is for its fresh key and signed by the key the hub publishes.
1583
    #[test]
1584
    fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() {
1585
        use oxy_core::identity::SecretKey;
1586
        let hub_sk = SecretKey::generate();
1587
        let hub_id = hub_sk.identity("hub", "", 1);
1588
        let sk = SecretKey::generate();
1589
        let id = sk.identity("earth-1 job 7", "", 1);
1590
        let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap();
1591
1592
        let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone());
1593
        let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap();
1594
        assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash()));
1595
1596
        let mallory_sk = SecretKey::generate();
1597
        let mallory = mallory_sk.identity("mallory", "", 1);
1598
        let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone());
1599
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
1600
        assert!(e.contains("not signed by the hub's published key"), "{e}");
1601
1602
        let other = SecretKey::generate().identity("other", "", 1);
1603
        let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id);
1604
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
1605
        assert!(e.contains("another key"), "{e}");
1606
    }
1607
1608
    fn hub_at(base: String) -> Hub {
1609
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
1610
    }
1611
1612
    /// oxygenhub#71: a job's result is offered until the hub takes it,
1613
    /// through server errors, and a refusal ends the trying at once.
1614
    #[test]
1615
    fn a_result_is_reported_until_the_hub_takes_it() {
1616
        let f = Finish {
1617
            exit_code: Some(0),
1618
            duration_ms: 1,
1619
            receipt: None,
1620
            signed_receipt: None,
1621
            error: None,
1622
            peak_disk_mib: None,
1623
            peak_memory_mib: None,
1624
        };
1625
        let quick = Duration::from_millis(10);
1626
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
1627
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
1628
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
1629
        let (base, seen) = fake_hub(vec![409]);
1630
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
1631
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1632
    }
1633
1634
    #[test]
1635
    fn server_errors_are_tried_again_and_client_errors_are_not() {
1636
        let quick = [Duration::from_millis(10); 3];
1637
        let (base, seen) = fake_hub(vec![500, 503, 204]);
1638
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
1639
        assert!(r.is_none());
1640
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
1641
1642
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
1643
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
1644
        assert!(e.contains("HTTP 500"), "{e}");
1645
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
1646
1647
        let (base, seen) = fake_hub(vec![401]);
1648
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
1649
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
1650
    }
1651
1652
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
1653
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
1654
    #[test]
1655
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
1656
        use microvm::Sink;
1657
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1658
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
1659
        let (base, seen) = fake_hub(vec![204]);
1660
        let hub = hub_at(base);
1661
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1662
        assert!(!sink.cancelled());
1663
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
1664
        assert_eq!(n(&seen), 1);
1665
1666
        let (base, seen) = fake_hub(vec![409]);
1667
        let hub = hub_at(base);
1668
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
1669
        assert!(sink.cancelled());
1670
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
1671
        sink.flush();
1672
        assert!(sink.cancelled());
1673
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1674
    }
1675
1676
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
1677
    /// it) costs a job's uploads a wait, not the upload, and costs its log
1678
    /// no lines.
1679
    #[test]
1680
    fn a_job_outlasts_a_hub_restart() {
1681
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1682
        // A port nobody listens on yet: the hub is restarting.
1683
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1684
        let base = format!("http://127.0.0.1:{port}");
1685
        let hub = hub_at(base.clone());
1686
1687
        // Log lines sent while it is away are kept, and go when it is back.
1688
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1689
        for i in 0..450 {
1690
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
1691
        }
1692
        sink.send();
1693
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
1694
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
1695
        sink.send();
1696
        assert!(sink.buf.is_empty() && !sink.gone);
1697
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
1698
1699
        // An upload waits for the hub to come back, through refused
1700
        // connections and server errors alike.
1701
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1702
        let url = format!("http://127.0.0.1:{port}/x");
1703
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
1704
        let later = std::thread::spawn(move || {
1705
            std::thread::sleep(Duration::from_millis(150));
1706
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
1707
        });
1708
        let quick = [Duration::from_millis(50); 20];
1709
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
1710
        assert_eq!(r.status(), 204);
1711
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
1712
        // Without `connection`, a hub that cannot be reached ends it at once.
1713
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1714
        let url = format!("http://127.0.0.1:{port}/x");
1715
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
1716
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
1717
    }
1718
1719
    /// oxygenflow#50: the first stop drains (the second, not called here,
1720
    /// exits at once).
1721
    #[test]
1722
    fn a_stop_drains_the_runner() {
1723
        assert!(!stopping());
1724
        on_stop(libc::SIGTERM);
1725
        assert!(stopping(), "told to stop, it claims nothing more");
1726
    }
1727
1728
    /// Lines kept for a hub that stays away are bounded, saying how many went.
1729
    #[test]
1730
    fn unsent_log_lines_are_bounded() {
1731
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1732
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
1733
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1734
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
1735
        sink.send();
1736
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
1737
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
1738
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
1739
    }
1740
1741
    #[test]
1742
    fn the_budget_admits_jobs_that_fit() {
1743
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
1744
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
1745
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
1746
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
1747
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
1748
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
1749
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
1750
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
1751
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
1752
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
1753
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
1754
    }
1755
}