OXYGENHUB
# de5cfe83a0eb oxygenflow / crates / flow / src / microvm.rs
9.8 KiB · Rust · last changed Build image for Rust and Node jobs (images/rust: toolchain, rustfmt, clippy, Node 24, vendored crates, offline cargo); flow-init finds the program in the job's PATH (glibc's execvpe used PID 1's own); network "loopback" in M0 1 month ago source blame history raw
e0918b88 Andy McCall 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;
fc10d9e8 Andy McCall ↶ 9
use flow_core::{Environment, Job, MountSource, Sandbox};
e0918b88 Andy McCall 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
}
22
23
/// The receipt environment for a job in this tier.
24
pub fn environment(home: &Home, sandbox: &Sandbox, job: &Job) -> Result<Environment> {
25
    let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?;
26
    Ok(Environment {
27
        runtime: "microvm".into(),
28
        parts: vec![
29
            ("firecracker".into(), cfg.firecracker.blake3.clone()),
30
            ("kernel".into(), cfg.kernel.blake3.clone()),
31
            ("init".into(), cfg.init.blake3.clone()),
32
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
33
            ("cpus".into(), sandbox.cpus.to_string()),
fc10d9e8 Andy McCall ↶ 34
        ]
35
        .into_iter()
36
        .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib))))
37
        .collect(),
e0918b88 Andy McCall 38
        rootfs: sandbox.rootfs.clone(),
39
        argv: job.exec.clone(),
40
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
41
    })
42
}
43
44
/// Boot, run, collect. `scratch` is an empty directory this call may fill.
45
pub fn run(
46
    home: &Home,
47
    sandbox: &Sandbox,
48
    job: &Job,
49
    workspace: &Path,
50
    scratch: &Path,
51
    timeout: Duration,
52
    sink: &mut dyn FnMut(Record),
53
) -> Result<Outcome> {
54
    let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?;
55
    let firecracker = home.verified(&home.resolve(&cfg.firecracker.path), &cfg.firecracker.blake3)?;
56
    let kernel = home.verified(&home.resolve(&cfg.kernel.path), &cfg.kernel.blake3)?;
57
    let init = home.verified(&home.resolve(&cfg.init.path), &cfg.init.blake3)?;
58
    let rootfs = home.rootfs(&sandbox.rootfs);
59
    if !rootfs.is_file() {
60
        bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs);
61
    }
62
    let rootfs = home.verified(&rootfs, &sandbox.rootfs)?;
63
    let environment = environment(home, sandbox, job)?;
64
eb5c4db6 Andy McCall ↶ 65
    let mut spec = guest_spec(job);
66
    spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback);
fc10d9e8 Andy McCall ↶ 67
    // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the
68
    // scratch directory, attached after the workspace (vdc, vdd, ...).
69
    let mut disks = Vec::new();
70
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
71
        let path = scratch.join(format!("mount-{i}.ext4"));
72
        crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
73
        disks.push(
74
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
75
        );
76
    }
e0918b88 Andy McCall 77
    let init_bytes = std::fs::read(&init)?;
78
    let spec_bytes = serde_json::to_vec(&spec)?;
79
    let initrd = crate::cpio::newc(&[
80
        crate::cpio::Entry { name: "init", mode: 0o755, data: &init_bytes },
81
        crate::cpio::Entry { name: "job.json", mode: 0o644, data: &spec_bytes },
82
    ]);
83
    let initrd_path = scratch.join("initrd.cpio");
84
    std::fs::write(&initrd_path, initrd)?;
fc10d9e8 Andy McCall ↶ 85
    let drives: Vec<serde_json::Value> = [
86
        serde_json::json!({ "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }),
87
        serde_json::json!({ "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }),
88
    ]
89
    .into_iter()
90
    .chain(disks)
91
    .collect();
e0918b88 Andy McCall 92
    let vm = serde_json::json!({
93
        "boot-source": {
94
            "kernel_image_path": kernel,
95
            "initrd_path": initrd_path,
96
            "boot_args": "console=ttyS0 reboot=k panic=-1 pci=off quiet loglevel=0 rdinit=/init",
97
        },
fc10d9e8 Andy McCall ↶ 98
        "drives": drives,
e0918b88 Andy McCall 99
        "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib },
100
    });
101
    let vm_path = scratch.join("vm.json");
102
    std::fs::write(&vm_path, serde_json::to_vec_pretty(&vm)?)?;
103
104
    let started = Instant::now();
105
    let mut child = std::process::Command::new(&firecracker)
106
        .arg("--no-api")
107
        .arg("--config-file")
108
        .arg(&vm_path)
109
        .args(["--level", "Error"])
110
        .current_dir(scratch)
111
        .stdin(std::process::Stdio::null())
112
        .stdout(std::process::Stdio::piped())
113
        .stderr(std::process::Stdio::piped())
114
        .spawn()
115
        .with_context(|| format!("start {}", firecracker.display()))?;
116
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
117
    let out = child.stdout.take().unwrap();
118
    let err = child.stderr.take().unwrap();
119
    let tx2 = tx.clone();
120
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
121
    let mut exit_code = None;
122
    let mut timed_out = false;
123
    let deadline = started + timeout;
124
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
125
    loop {
126
        match rx.recv_timeout(Duration::from_millis(200)) {
127
            Ok((from_console, line)) => {
128
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
129
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
130
                if !from_console {
131
                    sink(rec("console", &format!("firecracker: {text}")));
132
                } else if let Some(rest) = text.strip_prefix('\u{1e}') {
133
                    let (kind, body) = rest.split_at(rest.len().min(1));
134
                    let body = body.strip_prefix(' ').unwrap_or(body);
135
                    match kind {
136
                        "O" => sink(rec("out", body)),
137
                        "E" => sink(rec("err", body)),
138
                        "X" => exit_code = body.trim().parse().ok(),
139
                        "F" => sink(rec("flow", &format!("guest init failed: {body}"))),
140
                        _ => sink(rec("console", &text)),
141
                    }
142
                } else if !text.is_empty() {
143
                    sink(rec("console", &text));
144
                }
145
            }
146
            Err(mpsc::RecvTimeoutError::Timeout) => {}
147
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
148
        }
149
        if Instant::now() >= deadline && !timed_out {
150
            timed_out = true;
151
            let _ = child.kill();
152
            sink(Record { t: t(&started), stream: "flow".into(), text: format!("stopped at the time limit of {} s", timeout.as_secs()) });
153
        }
154
    }
155
    let status = child.wait()?;
156
    for r in readers {
157
        let _ = r.join();
158
    }
159
    if exit_code.is_none() && !timed_out {
160
        sink(Record { t: t(&started), stream: "flow".into(), text: format!("the microVM ended without the job's exit code ({status})") });
161
    }
162
    Ok(Outcome { exit_code: if timed_out { None } else { exit_code }, duration_ms: t(&started), environment, timed_out })
163
}
164
fc10d9e8 Andy McCall ↶ 165
/// What `flow-init` reads from `/job.json`: argv, env, and the mounts
166
/// with the device each disk mount is attached as (vdc onwards).
167
pub fn guest_spec(job: &Job) -> serde_json::Value {
168
    let mut next_disk = b'c';
169
    let mounts: Vec<serde_json::Value> = job
170
        .mounts
171
        .iter()
172
        .map(|m| match m.source {
173
            MountSource::Tmpfs => serde_json::json!({ "kind": "tmpfs", "target": m.target, "size_mib": m.size_mib }),
174
            MountSource::Disk => {
175
                let dev = format!("/dev/vd{}", next_disk as char);
176
                next_disk += 1;
177
                serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev })
178
            }
179
        })
180
        .collect();
181
    serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>(), "mounts": mounts })
182
}
183
e0918b88 Andy McCall 184
fn lines(r: impl Read, console: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
185
    let mut r = BufReader::new(r);
186
    let mut buf = Vec::new();
187
    loop {
188
        buf.clear();
189
        match r.read_until(b'\n', &mut buf) {
190
            Ok(0) | Err(_) => break,
191
            Ok(_) => {
192
                if buf.last() == Some(&b'\n') {
193
                    buf.pop();
194
                }
195
                if tx.send((console, buf.clone())).is_err() {
196
                    break;
197
                }
198
            }
199
        }
fc10d9e8 Andy McCall ↶ 200
    }
201
}
202
203
#[cfg(test)]
204
mod tests {
205
    use super::*;
206
    use flow_core::Mount;
207
208
    #[test]
209
    fn guest_spec_numbers_disks_after_the_workspace() {
210
        let mut job: Job = serde_json::from_value(serde_json::json!({
211
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"]
212
        }))
213
        .unwrap();
214
        assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts");
215
        job.mounts = vec![
216
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
217
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
218
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64 },
219
        ];
220
        let spec = guest_spec(&job);
221
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
222
        assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256}));
223
        assert_eq!(spec["mounts"][2]["device"], "/dev/vdd");
224
        assert_eq!(spec["env"], serde_json::json!([["A", "1"]]));
e0918b88 Andy McCall 225
    }
226
}