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