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