OXYGENHUB
# 82cf6e91d398 oxygenflow / crates / flow / src / microvm.rs
11.7 KiB · Rust · last changed A build cache: disk mounts that carry a build forward 22 days ago source blame history raw
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
}
26
27
/// Where a job's records go, and whether it is still wanted: asked a few
28
/// times a second while the job runs, and the microVM is stopped on `true`.
29
pub trait Sink {
30
    fn record(&mut self, r: Record);
31
    fn cancelled(&mut self) -> bool {
32
        false
33
    }
34
}
35
36
impl<F: FnMut(Record)> Sink for F {
37
    fn record(&mut self, r: Record) {
38
        self(r)
39
    }
40
}
41
42
/// The receipt environment for a job in this tier.
43
pub fn environment(home: &Home, sandbox: &Sandbox, job: &Job) -> Result<Environment> {
44
    let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?;
45
    Ok(Environment {
46
        runtime: "microvm".into(),
47
        parts: vec![
48
            ("firecracker".into(), cfg.firecracker.blake3.clone()),
49
            ("kernel".into(), cfg.kernel.blake3.clone()),
50
            ("init".into(), cfg.init.blake3.clone()),
51
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
52
            ("cpus".into(), sandbox.cpus.to_string()),
53
        ]
54
        .into_iter()
55
        .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib))))
56
        .collect(),
57
        rootfs: sandbox.rootfs.clone(),
58
        argv: job.exec.clone(),
59
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
60
    })
61
}
62
63
/// Boot, run, collect. `scratch` is an empty directory this call may fill.
64
#[allow(clippy::too_many_arguments)]
65
pub fn run(
66
    home: &Home,
67
    sandbox: &Sandbox,
68
    job: &Job,
69
    secrets: &[SecretInput],
70
    workspace: &Path,
71
    scratch: &Path,
72
    timeout: Duration,
73
    sink: &mut dyn Sink,
74
) -> Result<Outcome> {
75
    let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?;
76
    let firecracker = home.verified(&home.resolve(&cfg.firecracker.path), &cfg.firecracker.blake3)?;
77
    let kernel = home.verified(&home.resolve(&cfg.kernel.path), &cfg.kernel.blake3)?;
78
    let init = home.verified(&home.resolve(&cfg.init.path), &cfg.init.blake3)?;
79
    let rootfs = home.rootfs(&sandbox.rootfs);
80
    if !rootfs.is_file() {
81
        bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs);
82
    }
83
    let rootfs = home.verified(&rootfs, &sandbox.rootfs)?;
84
    let environment = environment(home, sandbox, job)?;
85
86
    let mut spec = guest_spec(job);
87
    spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback);
88
    // Secrets ride in the guest's job file, which only flow-init reads,
89
    // outside the root the job is confined to; it writes each as a file.
90
    if !secrets.is_empty() {
91
        spec["secrets"] =
92
            serde_json::Value::Array(secrets.iter().map(|s| serde_json::json!({ "path": s.path, "value": s.value })).collect());
93
    }
94
    // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the
95
    // scratch directory, attached after the workspace (vdc, vdd, ...).
96
    let mut disks = Vec::new();
97
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
98
        let path = scratch.join(format!("mount-{i}.ext4"));
99
        // A disk already in place came from the build cache (#28).
100
        if !path.exists() {
101
            crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
102
        }
103
        disks.push(
104
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
105
        );
106
    }
107
    let init_bytes = std::fs::read(&init)?;
108
    let spec_bytes = serde_json::to_vec(&spec)?;
109
    let initrd = crate::cpio::newc(&[
110
        crate::cpio::Entry { name: "init", mode: 0o755, data: &init_bytes },
111
        crate::cpio::Entry { name: "job.json", mode: 0o644, data: &spec_bytes },
112
    ]);
113
    let initrd_path = scratch.join("initrd.cpio");
114
    std::fs::write(&initrd_path, initrd)?;
115
    let drives: Vec<serde_json::Value> = [
116
        serde_json::json!({ "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }),
117
        serde_json::json!({ "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }),
118
    ]
119
    .into_iter()
120
    .chain(disks)
121
    .collect();
122
    let vm = serde_json::json!({
123
        "boot-source": {
124
            "kernel_image_path": kernel,
125
            "initrd_path": initrd_path,
126
            "boot_args": "console=ttyS0 reboot=k panic=-1 pci=off quiet loglevel=0 rdinit=/init",
127
        },
128
        "drives": drives,
129
        "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib },
130
    });
131
    let vm_path = scratch.join("vm.json");
132
    std::fs::write(&vm_path, serde_json::to_vec_pretty(&vm)?)?;
133
134
    let started = Instant::now();
135
    let mut child = std::process::Command::new(&firecracker)
136
        .arg("--no-api")
137
        .arg("--config-file")
138
        .arg(&vm_path)
139
        .args(["--level", "Error"])
140
        .current_dir(scratch)
141
        .stdin(std::process::Stdio::null())
142
        .stdout(std::process::Stdio::piped())
143
        .stderr(std::process::Stdio::piped())
144
        .spawn()
145
        .with_context(|| format!("start {}", firecracker.display()))?;
146
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
147
    let out = child.stdout.take().unwrap();
148
    let err = child.stderr.take().unwrap();
149
    let tx2 = tx.clone();
150
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
151
    let mut exit_code = None;
152
    let mut timed_out = false;
153
    let mut cancelled = false;
154
    let deadline = started + timeout;
155
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
156
    // The VMM's peak resident memory is the guest memory it touched: read
157
    // while it runs, since it is gone once it exits.
158
    let mut peak_memory_mib = None;
159
    loop {
160
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
161
            peak_memory_mib = Some(m);
162
        }
163
        match rx.recv_timeout(Duration::from_millis(200)) {
164
            Ok((from_console, line)) => {
165
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
166
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
167
                if !from_console {
168
                    sink.record(rec("console", &format!("firecracker: {text}")));
169
                } else if let Some(rest) = text.strip_prefix('\u{1e}') {
170
                    let (kind, body) = rest.split_at(rest.len().min(1));
171
                    let body = body.strip_prefix(' ').unwrap_or(body);
172
                    match kind {
173
                        "O" => sink.record(rec("out", body)),
174
                        "E" => sink.record(rec("err", body)),
175
                        "X" => exit_code = body.trim().parse().ok(),
176
                        "F" => sink.record(rec("flow", &format!("guest init failed: {body}"))),
177
                        _ => sink.record(rec("console", &text)),
178
                    }
179
                } else if !text.is_empty() {
180
                    sink.record(rec("console", &text));
181
                }
182
            }
183
            Err(mpsc::RecvTimeoutError::Timeout) => {}
184
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
185
        }
186
        if !timed_out && !cancelled && sink.cancelled() {
187
            cancelled = true;
188
            let _ = child.kill();
189
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
190
        }
191
        if Instant::now() >= deadline && !timed_out && !cancelled {
192
            timed_out = true;
193
            let _ = child.kill();
194
            sink.record(Record {
195
                t: t(&started),
196
                stream: "flow".into(),
197
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
198
            });
199
        }
200
    }
201
    let status = child.wait()?;
202
    for r in readers {
203
        let _ = r.join();
204
    }
205
    if exit_code.is_none() && !timed_out && !cancelled {
206
        sink.record(Record {
207
            t: t(&started),
208
            stream: "flow".into(),
209
            text: format!("the microVM ended without the job's exit code ({status})"),
210
        });
211
    }
212
    Ok(Outcome {
213
        exit_code: if timed_out || cancelled { None } else { exit_code },
214
        duration_ms: t(&started),
215
        environment,
216
        timed_out,
217
        cancelled,
218
        peak_memory_mib,
219
    })
220
}
221
222
/// What `flow-init` reads from `/job.json`: argv, env, and the mounts
223
/// with the device each disk mount is attached as (vdc onwards).
224
pub fn guest_spec(job: &Job) -> serde_json::Value {
225
    let mut next_disk = b'c';
226
    let mounts: Vec<serde_json::Value> = job
227
        .mounts
228
        .iter()
229
        .map(|m| match m.source {
230
            MountSource::Tmpfs => serde_json::json!({ "kind": "tmpfs", "target": m.target, "size_mib": m.size_mib }),
231
            MountSource::Disk => {
232
                let dev = format!("/dev/vd{}", next_disk as char);
233
                next_disk += 1;
234
                serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev })
235
            }
236
        })
237
        .collect();
238
    serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>(), "mounts": mounts })
239
}
240
241
fn lines(r: impl Read, console: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
242
    let mut r = BufReader::new(r);
243
    let mut buf = Vec::new();
244
    loop {
245
        buf.clear();
246
        match r.read_until(b'\n', &mut buf) {
247
            Ok(0) | Err(_) => break,
248
            Ok(_) => {
249
                if buf.last() == Some(&b'\n') {
250
                    buf.pop();
251
                }
252
                if tx.send((console, buf.clone())).is_err() {
253
                    break;
254
                }
255
            }
256
        }
257
    }
258
}
259
260
#[cfg(test)]
261
mod tests {
262
    use super::*;
263
    use flow_core::Mount;
264
265
    #[test]
266
    fn guest_spec_numbers_disks_after_the_workspace() {
267
        let mut job: Job = serde_json::from_value(serde_json::json!({
268
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"]
269
        }))
270
        .unwrap();
271
        assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts");
272
        job.mounts = vec![
273
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
274
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
275
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
276
        ];
277
        let spec = guest_spec(&job);
278
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
279
        assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256}));
280
        assert_eq!(spec["mounts"][2]["device"], "/dev/vdd");
281
        assert_eq!(spec["env"], serde_json::json!([["A", "1"]]));
282
    }
283
}