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