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