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