OXYGENHUB

Coverage / crates/flow/src/microvm.rs 31.2%

94 of 301 lines run, 15 of 55 functions · run #226 on main · run missed

1
//! Tier 2 (spec §4.3, docs/m0.md §2): one Firecracker microVM per job,
2
//! no network device, the rootfs read-only, the workspace read-write,
3
//! `flow-init` as PID 1. The host reads the framed console, enforces
4
//! the time limit, and writes the receipt's environment.
5
6
use crate::home::Home;
7
use anyhow::{bail, Context, Result};
8
use flow_core::api::{Record, SecretInput};
9
use flow_core::{Environment, Job, MountSource, Sandbox};
10
use std::io::{BufRead, BufReader, Read};
11
use std::path::Path;
12
use std::sync::mpsc;
13
use std::time::{Duration, Instant};
14
15
pub struct Outcome {
16
    /// None when the job never reported an exit (killed at the time limit, or the VM failed).
17
    pub exit_code: Option<i32>,
18
    pub duration_ms: u64,
19
    pub environment: Environment,
20
    pub timed_out: bool,
21
    /// Stopped because the run was cancelled (oxygenflow#32).
22
    pub cancelled: bool,
23
    /// The most memory the sandbox held, in MiB (oxygenflow#41).
24
    pub peak_memory_mib: Option<u64>,
25
    /// Each cache disk the guest pruned before it stopped, by its target
26
    /// (oxygenflow#72); the syscall tier's are pruned by the runner.
27
    pub pruned: Vec<(String, flow_init::prune::Pruned)>,
28
}
29
30
/// Where a job's records go, and whether it is still wanted: asked a few
31
/// times a second while the job runs, and the microVM is stopped on `true`.
32
pub trait Sink {
33
    fn record(&mut self, r: Record);
3415×
    fn cancelled(&mut self) -> bool {
3515×
        false
3615×
    }
37
}
38
39
impl<F: FnMut(Record)> Sink for F {
4017×
    fn record(&mut self, r: Record) {
4117×
        self(r)
4217×
    }
43
}
44
45
/// The receipt environment for a job in this tier.
460×
pub fn environment(home: &Home, sandbox: &Sandbox, job: &Job) -> Result<Environment> {
470×
    let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?;
48
    Ok(Environment {
490×
        runtime: "microvm".into(),
500×
        parts: vec![
510×
            ("firecracker".into(), cfg.firecracker.blake3.clone()),
520×
            ("kernel".into(), cfg.kernel.blake3.clone()),
530×
            ("init".into(), cfg.init.blake3.clone()),
540×
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
550×
            ("cpus".into(), sandbox.cpus.to_string()),
56
        ]
570×
        .into_iter()
580×
        .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib))))
590×
        .collect(),
600×
        rootfs: sandbox.rootfs.clone(),
610×
        argv: job.exec.clone(),
620×
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
63
    })
640×
}
65
66
/// Boot, run, collect. `scratch` is an empty directory this call may fill.
67
#[allow(clippy::too_many_arguments)]
680×
pub fn run(
690×
    home: &Home,
700×
    sandbox: &Sandbox,
710×
    job: &Job,
720×
    secrets: &[SecretInput],
730×
    workspace: &Path,
740×
    scratch: &Path,
750×
    timeout: Duration,
760×
    sink: &mut dyn Sink,
770×
) -> Result<Outcome> {
780×
    let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?;
790×
    let firecracker = home.verified(&home.resolve(&cfg.firecracker.path), &cfg.firecracker.blake3)?;
800×
    let kernel = home.verified(&home.resolve(&cfg.kernel.path), &cfg.kernel.blake3)?;
810×
    let init = home.verified(&home.resolve(&cfg.init.path), &cfg.init.blake3)?;
820×
    let rootfs = home.rootfs(&sandbox.rootfs);
830×
    if !rootfs.is_file() {
840×
        bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs);
850×
    }
860×
    let rootfs = home.verified(&rootfs, &sandbox.rootfs)?;
870×
    let environment = environment(home, sandbox, job)?;
88
890×
    let percent = home.config.runner.as_ref().and_then(|r| r.cache_prune_percent).unwrap_or(flow_init::prune::DEFAULT_PERCENT);
900×
    let mut spec = guest_spec(job, percent);
910×
    spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback);
92
    // Allow-listed egress (oxygenflow#55): a vsock device and the runner's
93
    // proxy behind it; the guest relays 127.0.0.1:3128 to it.
940×
    let egress = sandbox.network == flow_core::Network::Egress;
950×
    let (egress_tx, egress_rx) = mpsc::channel::<String>();
960×
    let mut _proxy = None;
970×
    if egress {
980×
        if !home.config.runner.as_ref().is_some_and(|r| r.egress) {
990×
            bail!("this runner does not allow network egress (a runner allows it with `egress = true` under [runner] in flow.toml)");
1000×
        }
1010×
        _proxy = Some(
1020×
            crate::egress::start(&scratch.join(format!("v.sock_{}", crate::egress::VSOCK_PORT)), sandbox.egress_allow.clone(), egress_tx)
1030×
                .context("start the egress proxy")?,
104
        );
1050×
        spec["egress"] = serde_json::Value::Bool(true);
1060×
        add_proxy_env(&mut spec);
1070×
        sink.record(Record {
1080×
            t: 0,
1090×
            stream: "flow".into(),
1100×
            text: format!("egress: through this runner, to {} only", sandbox.egress_allow.join(", ")),
1110×
        });
1120×
    }
113
    // Secrets ride in the guest's job file, which only flow-init reads,
114
    // outside the root the job is confined to; it writes each as a file.
1150×
    if !secrets.is_empty() {
1160×
        spec["secrets"] =
1170×
            serde_json::Value::Array(secrets.iter().map(|s| serde_json::json!({ "path": s.path, "value": s.value })).collect());
1180×
    }
119
    // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the
120
    // scratch directory, attached after the workspace (vdc, vdd, ...).
1210×
    let mut disks = Vec::new();
1220×
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
1230×
        let path = scratch.join(format!("mount-{i}.ext4"));
124
        // A disk already in place came from the build cache (#28).
1250×
        if !path.exists() {
1260×
            crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
1270×
        }
1280×
        disks.push(
1290×
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
130
        );
131
    }
1320×
    let init_bytes = std::fs::read(&init)?;
1330×
    let spec_bytes = serde_json::to_vec(&spec)?;
1340×
    let initrd = crate::cpio::newc(&[
1350×
        crate::cpio::Entry { name: "init", mode: 0o755, data: &init_bytes },
1360×
        crate::cpio::Entry { name: "job.json", mode: 0o644, data: &spec_bytes },
1370×
    ]);
1380×
    let initrd_path = scratch.join("initrd.cpio");
1390×
    std::fs::write(&initrd_path, initrd)?;
1400×
    let drives: Vec<serde_json::Value> = [
1410×
        serde_json::json!({ "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }),
1420×
        serde_json::json!({ "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }),
1430×
    ]
1440×
    .into_iter()
1450×
    .chain(disks)
1460×
    .collect();
1470×
    let vm = serde_json::json!({
1480×
        "boot-source": {
1490×
            "kernel_image_path": kernel,
1500×
            "initrd_path": initrd_path,
1510×
            "boot_args": "console=ttyS0 reboot=k panic=-1 pci=off quiet loglevel=0 rdinit=/init",
152
        },
1530×
        "drives": drives,
1540×
        "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib },
155
    });
1560×
    let mut vm = vm;
1570×
    if egress {
1580×
        // Relative to the VMM's working directory (the scratch directory),
1590×
        // which keeps the socket path short.
1600×
        vm["vsock"] = serde_json::json!({ "guest_cid": 3, "uds_path": "v.sock" });
1610×
    }
1620×
    let vm_path = scratch.join("vm.json");
1630×
    std::fs::write(&vm_path, serde_json::to_vec_pretty(&vm)?)?;
164
1650×
    let started = Instant::now();
1660×
    let mut child = std::process::Command::new(&firecracker)
1670×
        .arg("--no-api")
1680×
        .arg("--config-file")
1690×
        .arg(&vm_path)
1700×
        .args(["--level", "Error"])
1710×
        .current_dir(scratch)
1720×
        .stdin(std::process::Stdio::null())
1730×
        .stdout(std::process::Stdio::piped())
1740×
        .stderr(std::process::Stdio::piped())
1750×
        .spawn()
1760×
        .with_context(|| format!("start {}", firecracker.display()))?;
1770×
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
1780×
    let out = child.stdout.take().unwrap();
1790×
    let err = child.stderr.take().unwrap();
1800×
    let tx2 = tx.clone();
1810×
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
1820×
    let mut exit_code = None;
1830×
    let mut timed_out = false;
1840×
    let mut cancelled = false;
1850×
    let deadline = started + timeout;
1860×
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
187
    // The VMM's peak resident memory is the guest memory it touched: read
188
    // while it runs, since it is gone once it exits.
1890×
    let mut peak_memory_mib = None;
1900×
    let mut pruned = Vec::new();
191
    loop {
1920×
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
1930×
            peak_memory_mib = Some(m);
1940×
        }
1950×
        while let Ok(line) = egress_rx.try_recv() {
1960×
            sink.record(Record { t: t(&started), stream: "flow".into(), text: line });
1970×
        }
1980×
        match rx.recv_timeout(Duration::from_millis(200)) {
1990×
            Ok((from_console, line)) => {
2000×
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
2010×
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
2020×
                if !from_console {
2030×
                    sink.record(rec("console", &format!("firecracker: {text}")));
2040×
                } else if let Some(rest) = text.strip_prefix('\u{1e}') {
2050×
                    let (kind, body) = rest.split_at(rest.len().min(1));
2060×
                    let body = body.strip_prefix(' ').unwrap_or(body);
2070×
                    match kind {
2080×
                        "O" => sink.record(rec("out", body)),
2090×
                        "E" => sink.record(rec("err", body)),
2100×
                        "X" => exit_code = body.trim().parse().ok(),
2110×
                        "F" => sink.record(rec("flow", &format!("guest init failed: {body}"))),
2120×
                        "C" => match pruned_frame(body) {
2130×
                            Some(p) => pruned.push(p),
2140×
                            None => sink.record(rec("console", &text)),
215
                        },
2160×
                        _ => sink.record(rec("console", &text)),
217
                    }
2180×
                } else if !text.is_empty() {
2190×
                    sink.record(rec("console", &text));
2200×
                }
221
            }
2220×
            Err(mpsc::RecvTimeoutError::Timeout) => {}
2230×
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
224
        }
2250×
        if !timed_out && !cancelled && sink.cancelled() {
2260×
            cancelled = true;
2270×
            let _ = child.kill();
2280×
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
2290×
        }
2300×
        if Instant::now() >= deadline && !timed_out && !cancelled {
2310×
            timed_out = true;
2320×
            let _ = child.kill();
2330×
            sink.record(Record {
2340×
                t: t(&started),
2350×
                stream: "flow".into(),
2360×
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
2370×
            });
2380×
        }
239
    }
2400×
    let status = child.wait()?;
2410×
    for r in readers {
2420×
        let _ = r.join();
2430×
    }
2440×
    if exit_code.is_none() && !timed_out && !cancelled {
2450×
        sink.record(Record {
2460×
            t: t(&started),
2470×
            stream: "flow".into(),
2480×
            text: format!("the microVM ended without the job's exit code ({status})"),
2490×
        });
2500×
    }
251
    Ok(Outcome {
2520×
        exit_code: if timed_out || cancelled { None } else { exit_code },
2530×
        duration_ms: t(&started),
2540×
        environment,
2550×
        timed_out,
2560×
        cancelled,
2570×
        peak_memory_mib,
2580×
        pruned,
259
    })
2600×
}
261
262
/// A `\x1eC` frame: a cache disk the guest pruned (oxygenflow#72).
2632×
fn pruned_frame(body: &str) -> Option<(String, flow_init::prune::Pruned)> {
264
    #[derive(serde::Deserialize)]
265
    struct Frame {
266
        target: String,
267
        pruned: flow_init::prune::Pruned,
268
    }
2692×
    let f: Frame = serde_json::from_str(body).ok()?;
2701×
    Some((f.target, f.pruned))
2712×
}
272
273
/// The proxy variables a job with egress gets, in both spellings tools
274
/// read, unless the job sets them itself.
2751×
fn add_proxy_env(spec: &mut serde_json::Value) {
2761×
    let Some(env) = spec["env"].as_array_mut() else { return };
2771×
    let set: Vec<String> = env.iter().filter_map(|kv| kv[0].as_str().map(String::from)).collect();
2786×
    for (k, v) in [
2791×
        ("HTTPS_PROXY", crate::egress::GUEST_PROXY),
2801×
        ("https_proxy", crate::egress::GUEST_PROXY),
2811×
        ("HTTP_PROXY", crate::egress::GUEST_PROXY),
2821×
        ("http_proxy", crate::egress::GUEST_PROXY),
2831×
        ("NO_PROXY", "localhost,127.0.0.1"),
2841×
        ("no_proxy", "localhost,127.0.0.1"),
2851×
    ] {
2866×
        if !set.iter().any(|s| s == k) {
2875×
            env.push(serde_json::json!([k, v]));
2885×
        }
289
    }
2901×
}
291
292
/// What `flow-init` reads from `/job.json`: argv, env, and the mounts
293
/// with the device each disk mount is attached as (vdc onwards). A cache
294
/// disk is pruned to `percent` of its size after a passing job
295
/// (oxygenflow#72), leaving the job's outputs on it.
2963×
pub fn guest_spec(job: &Job, percent: u8) -> serde_json::Value {
2973×
    let mut next_disk = b'c';
2983×
    let mounts: Vec<serde_json::Value> = job
2993×
        .mounts
3003×
        .iter()
3015×
        .map(|m| match m.source {
3021×
            MountSource::Tmpfs => serde_json::json!({ "kind": "tmpfs", "target": m.target, "size_mib": m.size_mib }),
303
            MountSource::Disk => {
3044×
                let dev = format!("/dev/vd{}", next_disk as char);
3054×
                next_disk += 1;
3064×
                let mut disk = serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev });
3074×
                if m.cache.is_some() {
3081×
                    let prefix = format!("{}/", m.target);
3092×
                    let keep: Vec<&str> = job.outputs.iter().filter_map(|o| o.path.strip_prefix(&prefix)).collect();
3101×
                    disk["prune"] = percent.into();
3111×
                    disk["keep"] = keep.into();
3123×
                }
3134×
                disk
314
            }
3155×
        })
3163×
        .collect();
3173×
    serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>(), "mounts": mounts })
3183×
}
319
3200×
fn lines(r: impl Read, console: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
3210×
    let mut r = BufReader::new(r);
3220×
    let mut buf = Vec::new();
323
    loop {
3240×
        buf.clear();
3250×
        match r.read_until(b'\n', &mut buf) {
3260×
            Ok(0) | Err(_) => break,
327
            Ok(_) => {
3280×
                if buf.last() == Some(&b'\n') {
3290×
                    buf.pop();
3300×
                }
3310×
                if tx.send((console, buf.clone())).is_err() {
3320×
                    break;
3330×
                }
334
            }
335
        }
336
    }
3370×
}
338
339
#[cfg(test)]
340
mod tests {
341
    use super::*;
342
    use flow_core::Mount;
343
344
    #[test]
3451×
    fn a_job_with_egress_gets_the_proxy_variables_unless_it_sets_them() {
3461×
        let mut spec = serde_json::json!({ "env": [["HTTPS_PROXY", "http://mine:1"]] });
3471×
        super::add_proxy_env(&mut spec);
3481×
        let env = spec["env"].as_array().unwrap();
3498×
        let get = |k: &str| env.iter().find(|kv| kv[0] == k).map(|kv| kv[1].as_str().unwrap().to_string());
3501×
        assert_eq!(get("HTTPS_PROXY").as_deref(), Some("http://mine:1"), "the job's own is kept");
3511×
        assert_eq!(get("https_proxy").as_deref(), Some("http://127.0.0.1:3128"));
3521×
        assert_eq!(get("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
3531×
    }
354
355
    #[test]
3561×
    fn guest_spec_numbers_disks_after_the_workspace() {
3571×
        let mut job: Job = serde_json::from_value(serde_json::json!({
3581×
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"]
359
        }))
3601×
        .unwrap();
3611×
        assert_eq!(guest_spec(&job, 60)["mounts"], serde_json::json!([]), "no mounts");
3621×
        job.mounts = vec![
3631×
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
3641×
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
3651×
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
366
        ];
3671×
        let spec = guest_spec(&job, 60);
3681×
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
3691×
        assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256}));
3701×
        assert_eq!(spec["mounts"][2]["device"], "/dev/vdd");
3711×
        assert_eq!(spec["env"], serde_json::json!([["A", "1"]]));
3721×
    }
373
374
    /// oxygenflow#72: a cache disk is pruned in the guest, leaving the
375
    /// job's outputs on it; a plain disk is not, and the guest's report
376
    /// comes back as a frame.
377
    #[test]
3781×
    fn a_cache_disk_is_pruned_in_the_guest_but_its_outputs_stay() {
3791×
        let mut job: Job = serde_json::from_value(serde_json::json!({
3801×
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {}, "exec": ["true"],
3811×
            "outputs": [{"name": "bin", "path": "/src/target/release/tool"}, {"name": "w", "path": "/src/w"}]
382
        }))
3831×
        .unwrap();
3841×
        job.mounts = vec![
3851×
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: Some("t".into()), key: vec![] },
3861×
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
387
        ];
3881×
        let spec = guest_spec(&job, 45);
3891×
        assert_eq!((spec["mounts"][0]["prune"].as_u64(), &spec["mounts"][0]["keep"]), (Some(45), &serde_json::json!(["release/tool"])));
3901×
        assert!(spec["mounts"][1].get("prune").is_none() && spec["mounts"][1].get("keep").is_none());
3911×
        let (target, p) =
3921×
            pruned_frame(r#"{"target":"/src/target","pruned":{"size":100,"percent":60,"before":90,"after":50,"freed":40,"files":3}}"#)
3931×
                .unwrap();
3941×
        assert_eq!((target.as_str(), p.after, p.cap(), p.fits(), p.unpruned), ("/src/target", 50, 60, true, None));
3951×
        assert!(pruned_frame("not json").is_none());
3961×
    }
397
}