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