OXYGENHUB
◆ push-uses-login oxygenflow / crates / flow / src / microvm.rs
11.5 KiB · Rust · last changed A job's finish carries its peak disk and memory 24 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
        crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
100
        disks.push(
101
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
102
        );
103
    }
104
    let init_bytes = std::fs::read(&init)?;
105
    let spec_bytes = serde_json::to_vec(&spec)?;
106
    let initrd = crate::cpio::newc(&[
107
        crate::cpio::Entry { name: "init", mode: 0o755, data: &init_bytes },
108
        crate::cpio::Entry { name: "job.json", mode: 0o644, data: &spec_bytes },
109
    ]);
110
    let initrd_path = scratch.join("initrd.cpio");
111
    std::fs::write(&initrd_path, initrd)?;
112
    let drives: Vec<serde_json::Value> = [
113
        serde_json::json!({ "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }),
114
        serde_json::json!({ "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }),
115
    ]
116
    .into_iter()
117
    .chain(disks)
118
    .collect();
119
    let vm = serde_json::json!({
120
        "boot-source": {
121
            "kernel_image_path": kernel,
122
            "initrd_path": initrd_path,
123
            "boot_args": "console=ttyS0 reboot=k panic=-1 pci=off quiet loglevel=0 rdinit=/init",
124
        },
125
        "drives": drives,
126
        "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib },
127
    });
128
    let vm_path = scratch.join("vm.json");
129
    std::fs::write(&vm_path, serde_json::to_vec_pretty(&vm)?)?;
130
131
    let started = Instant::now();
132
    let mut child = std::process::Command::new(&firecracker)
133
        .arg("--no-api")
134
        .arg("--config-file")
135
        .arg(&vm_path)
136
        .args(["--level", "Error"])
137
        .current_dir(scratch)
138
        .stdin(std::process::Stdio::null())
139
        .stdout(std::process::Stdio::piped())
140
        .stderr(std::process::Stdio::piped())
141
        .spawn()
142
        .with_context(|| format!("start {}", firecracker.display()))?;
143
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
144
    let out = child.stdout.take().unwrap();
145
    let err = child.stderr.take().unwrap();
146
    let tx2 = tx.clone();
147
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
148
    let mut exit_code = None;
149
    let mut timed_out = false;
150
    let mut cancelled = false;
151
    let deadline = started + timeout;
152
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
153
    // The VMM's peak resident memory is the guest memory it touched: read
154
    // while it runs, since it is gone once it exits.
155
    let mut peak_memory_mib = None;
156
    loop {
157
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
158
            peak_memory_mib = Some(m);
159
        }
160
        match rx.recv_timeout(Duration::from_millis(200)) {
161
            Ok((from_console, line)) => {
162
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
163
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
164
                if !from_console {
165
                    sink.record(rec("console", &format!("firecracker: {text}")));
166
                } else if let Some(rest) = text.strip_prefix('\u{1e}') {
167
                    let (kind, body) = rest.split_at(rest.len().min(1));
168
                    let body = body.strip_prefix(' ').unwrap_or(body);
169
                    match kind {
170
                        "O" => sink.record(rec("out", body)),
171
                        "E" => sink.record(rec("err", body)),
172
                        "X" => exit_code = body.trim().parse().ok(),
173
                        "F" => sink.record(rec("flow", &format!("guest init failed: {body}"))),
174
                        _ => sink.record(rec("console", &text)),
175
                    }
176
                } else if !text.is_empty() {
177
                    sink.record(rec("console", &text));
178
                }
179
            }
180
            Err(mpsc::RecvTimeoutError::Timeout) => {}
181
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
182
        }
183
        if !timed_out && !cancelled && sink.cancelled() {
184
            cancelled = true;
185
            let _ = child.kill();
186
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
187
        }
188
        if Instant::now() >= deadline && !timed_out && !cancelled {
189
            timed_out = true;
190
            let _ = child.kill();
191
            sink.record(Record {
192
                t: t(&started),
193
                stream: "flow".into(),
194
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
195
            });
196
        }
197
    }
198
    let status = child.wait()?;
199
    for r in readers {
200
        let _ = r.join();
201
    }
202
    if exit_code.is_none() && !timed_out && !cancelled {
203
        sink.record(Record {
204
            t: t(&started),
205
            stream: "flow".into(),
206
            text: format!("the microVM ended without the job's exit code ({status})"),
207
        });
208
    }
209
    Ok(Outcome {
210
        exit_code: if timed_out || cancelled { None } else { exit_code },
211
        duration_ms: t(&started),
212
        environment,
213
        timed_out,
214
        cancelled,
215
        peak_memory_mib,
216
    })
217
}
218
219
/// What `flow-init` reads from `/job.json`: argv, env, and the mounts
220
/// with the device each disk mount is attached as (vdc onwards).
221
pub fn guest_spec(job: &Job) -> serde_json::Value {
222
    let mut next_disk = b'c';
223
    let mounts: Vec<serde_json::Value> = job
224
        .mounts
225
        .iter()
226
        .map(|m| match m.source {
227
            MountSource::Tmpfs => serde_json::json!({ "kind": "tmpfs", "target": m.target, "size_mib": m.size_mib }),
228
            MountSource::Disk => {
229
                let dev = format!("/dev/vd{}", next_disk as char);
230
                next_disk += 1;
231
                serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev })
232
            }
233
        })
234
        .collect();
235
    serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>(), "mounts": mounts })
236
}
237
238
fn lines(r: impl Read, console: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
239
    let mut r = BufReader::new(r);
240
    let mut buf = Vec::new();
241
    loop {
242
        buf.clear();
243
        match r.read_until(b'\n', &mut buf) {
244
            Ok(0) | Err(_) => break,
245
            Ok(_) => {
246
                if buf.last() == Some(&b'\n') {
247
                    buf.pop();
248
                }
249
                if tx.send((console, buf.clone())).is_err() {
250
                    break;
251
                }
252
            }
253
        }
254
    }
255
}
256
257
#[cfg(test)]
258
mod tests {
259
    use super::*;
260
    use flow_core::Mount;
261
262
    #[test]
263
    fn guest_spec_numbers_disks_after_the_workspace() {
264
        let mut job: Job = serde_json::from_value(serde_json::json!({
265
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"]
266
        }))
267
        .unwrap();
268
        assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts");
269
        job.mounts = vec![
270
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
271
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
272
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64 },
273
        ];
274
        let spec = guest_spec(&job);
275
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
276
        assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256}));
277
        assert_eq!(spec["mounts"][2]["device"], "/dev/vdd");
278
        assert_eq!(spec["env"], serde_json::json!([["A", "1"]]));
279
    }
280
}