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