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