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