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