OXYGENHUB
# f1d14bb6ec2b oxygenflow / crates / flow / src / microvm.rs
14.5 KiB · Rust · last changed Allow-listed egress over vsock: network "egress" { allow "host"... } 20 days 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};
e2c12561 Andy McCall ↶ 8
use flow_core::api::{Record, SecretInput};
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,
280ef951 Andy McCall ↶ 21
    /// Stopped because the run was cancelled (oxygenflow#32).
22
    pub cancelled: bool,
8f211e20 Andy McCall ↶ 23
    /// The most memory the sandbox held, in MiB (oxygenflow#41).
24
    pub peak_memory_mib: Option<u64>,
e0918b88 Andy McCall 25
}
26
280ef951 Andy McCall ↶ 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
e0918b88 Andy McCall 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()),
fc10d9e8 Andy McCall ↶ 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(),
e0918b88 Andy McCall 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.
e2c12561 Andy McCall ↶ 64
#[allow(clippy::too_many_arguments)]
e0918b88 Andy McCall 65
pub fn run(
66
    home: &Home,
67
    sandbox: &Sandbox,
68
    job: &Job,
e2c12561 Andy McCall ↶ 69
    secrets: &[SecretInput],
e0918b88 Andy McCall 70
    workspace: &Path,
71
    scratch: &Path,
72
    timeout: Duration,
280ef951 Andy McCall ↶ 73
    sink: &mut dyn Sink,
e0918b88 Andy McCall 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
eb5c4db6 Andy McCall ↶ 86
    let mut spec = guest_spec(job);
87
    spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback);
ea81796b Andy McCall ↶ 88
    // Allow-listed egress (oxygenflow#55): a vsock device and the runner's
89
    // proxy behind it; the guest relays 127.0.0.1:3128 to it.
90
    let egress = sandbox.network == flow_core::Network::Egress;
91
    let (egress_tx, egress_rx) = mpsc::channel::<String>();
92
    let mut _proxy = None;
93
    if egress {
94
        if !home.config.runner.as_ref().is_some_and(|r| r.egress) {
95
            bail!("this runner does not allow network egress (a runner allows it with `egress = true` under [runner] in flow.toml)");
96
        }
97
        _proxy = Some(
98
            crate::egress::start(&scratch.join(format!("v.sock_{}", crate::egress::VSOCK_PORT)), sandbox.egress_allow.clone(), egress_tx)
99
                .context("start the egress proxy")?,
100
        );
101
        spec["egress"] = serde_json::Value::Bool(true);
102
        add_proxy_env(&mut spec);
103
        sink.record(Record {
104
            t: 0,
105
            stream: "flow".into(),
106
            text: format!("egress: through this runner, to {} only", sandbox.egress_allow.join(", ")),
107
        });
108
    }
e2c12561 Andy McCall ↶ 109
    // Secrets ride in the guest's job file, which only flow-init reads,
110
    // outside the root the job is confined to; it writes each as a file.
111
    if !secrets.is_empty() {
112
        spec["secrets"] =
113
            serde_json::Value::Array(secrets.iter().map(|s| serde_json::json!({ "path": s.path, "value": s.value })).collect());
114
    }
fc10d9e8 Andy McCall ↶ 115
    // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the
116
    // scratch directory, attached after the workspace (vdc, vdd, ...).
117
    let mut disks = Vec::new();
118
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
119
        let path = scratch.join(format!("mount-{i}.ext4"));
d60aac6c Andy McCall ↶ 120
        // A disk already in place came from the build cache (#28).
121
        if !path.exists() {
122
            crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
123
        }
fc10d9e8 Andy McCall ↶ 124
        disks.push(
125
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
126
        );
127
    }
e0918b88 Andy McCall 128
    let init_bytes = std::fs::read(&init)?;
129
    let spec_bytes = serde_json::to_vec(&spec)?;
130
    let initrd = crate::cpio::newc(&[
131
        crate::cpio::Entry { name: "init", mode: 0o755, data: &init_bytes },
132
        crate::cpio::Entry { name: "job.json", mode: 0o644, data: &spec_bytes },
133
    ]);
134
    let initrd_path = scratch.join("initrd.cpio");
135
    std::fs::write(&initrd_path, initrd)?;
fc10d9e8 Andy McCall ↶ 136
    let drives: Vec<serde_json::Value> = [
137
        serde_json::json!({ "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }),
138
        serde_json::json!({ "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }),
139
    ]
140
    .into_iter()
141
    .chain(disks)
142
    .collect();
e0918b88 Andy McCall 143
    let vm = serde_json::json!({
144
        "boot-source": {
145
            "kernel_image_path": kernel,
146
            "initrd_path": initrd_path,
147
            "boot_args": "console=ttyS0 reboot=k panic=-1 pci=off quiet loglevel=0 rdinit=/init",
148
        },
fc10d9e8 Andy McCall ↶ 149
        "drives": drives,
e0918b88 Andy McCall 150
        "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib },
151
    });
ea81796b Andy McCall ↶ 152
    let mut vm = vm;
153
    if egress {
154
        // Relative to the VMM's working directory (the scratch directory),
155
        // which keeps the socket path short.
156
        vm["vsock"] = serde_json::json!({ "guest_cid": 3, "uds_path": "v.sock" });
157
    }
e0918b88 Andy McCall 158
    let vm_path = scratch.join("vm.json");
159
    std::fs::write(&vm_path, serde_json::to_vec_pretty(&vm)?)?;
160
161
    let started = Instant::now();
162
    let mut child = std::process::Command::new(&firecracker)
163
        .arg("--no-api")
164
        .arg("--config-file")
165
        .arg(&vm_path)
166
        .args(["--level", "Error"])
167
        .current_dir(scratch)
168
        .stdin(std::process::Stdio::null())
169
        .stdout(std::process::Stdio::piped())
170
        .stderr(std::process::Stdio::piped())
171
        .spawn()
172
        .with_context(|| format!("start {}", firecracker.display()))?;
173
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
174
    let out = child.stdout.take().unwrap();
175
    let err = child.stderr.take().unwrap();
176
    let tx2 = tx.clone();
177
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
178
    let mut exit_code = None;
179
    let mut timed_out = false;
280ef951 Andy McCall ↶ 180
    let mut cancelled = false;
e0918b88 Andy McCall 181
    let deadline = started + timeout;
182
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
8f211e20 Andy McCall ↶ 183
    // The VMM's peak resident memory is the guest memory it touched: read
184
    // while it runs, since it is gone once it exits.
185
    let mut peak_memory_mib = None;
e0918b88 Andy McCall 186
    loop {
8f211e20 Andy McCall ↶ 187
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
188
            peak_memory_mib = Some(m);
ea81796b Andy McCall ↶ 189
        }
190
        while let Ok(line) = egress_rx.try_recv() {
191
            sink.record(Record { t: t(&started), stream: "flow".into(), text: line });
8f211e20 Andy McCall ↶ 192
        }
e0918b88 Andy McCall 193
        match rx.recv_timeout(Duration::from_millis(200)) {
194
            Ok((from_console, line)) => {
195
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
196
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
197
                if !from_console {
280ef951 Andy McCall ↶ 198
                    sink.record(rec("console", &format!("firecracker: {text}")));
e0918b88 Andy McCall 199
                } else if let Some(rest) = text.strip_prefix('\u{1e}') {
200
                    let (kind, body) = rest.split_at(rest.len().min(1));
201
                    let body = body.strip_prefix(' ').unwrap_or(body);
202
                    match kind {
280ef951 Andy McCall ↶ 203
                        "O" => sink.record(rec("out", body)),
204
                        "E" => sink.record(rec("err", body)),
e0918b88 Andy McCall 205
                        "X" => exit_code = body.trim().parse().ok(),
280ef951 Andy McCall ↶ 206
                        "F" => sink.record(rec("flow", &format!("guest init failed: {body}"))),
207
                        _ => sink.record(rec("console", &text)),
e0918b88 Andy McCall 208
                    }
209
                } else if !text.is_empty() {
280ef951 Andy McCall ↶ 210
                    sink.record(rec("console", &text));
e0918b88 Andy McCall 211
                }
212
            }
213
            Err(mpsc::RecvTimeoutError::Timeout) => {}
214
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
280ef951 Andy McCall ↶ 215
        }
216
        if !timed_out && !cancelled && sink.cancelled() {
217
            cancelled = true;
218
            let _ = child.kill();
219
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
e0918b88 Andy McCall 220
        }
280ef951 Andy McCall ↶ 221
        if Instant::now() >= deadline && !timed_out && !cancelled {
e0918b88 Andy McCall 222
            timed_out = true;
223
            let _ = child.kill();
280ef951 Andy McCall ↶ 224
            sink.record(Record {
225
                t: t(&started),
226
                stream: "flow".into(),
227
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
228
            });
e0918b88 Andy McCall 229
        }
230
    }
231
    let status = child.wait()?;
232
    for r in readers {
233
        let _ = r.join();
234
    }
280ef951 Andy McCall ↶ 235
    if exit_code.is_none() && !timed_out && !cancelled {
236
        sink.record(Record {
237
            t: t(&started),
238
            stream: "flow".into(),
239
            text: format!("the microVM ended without the job's exit code ({status})"),
240
        });
e0918b88 Andy McCall 241
    }
280ef951 Andy McCall ↶ 242
    Ok(Outcome {
243
        exit_code: if timed_out || cancelled { None } else { exit_code },
244
        duration_ms: t(&started),
245
        environment,
246
        timed_out,
247
        cancelled,
8f211e20 Andy McCall ↶ 248
        peak_memory_mib,
280ef951 Andy McCall ↶ 249
    })
e0918b88 Andy McCall 250
}
251
ea81796b Andy McCall ↶ 252
/// The proxy variables a job with egress gets, in both spellings tools
253
/// read, unless the job sets them itself.
254
fn add_proxy_env(spec: &mut serde_json::Value) {
255
    let Some(env) = spec["env"].as_array_mut() else { return };
256
    let set: Vec<String> = env.iter().filter_map(|kv| kv[0].as_str().map(String::from)).collect();
257
    for (k, v) in [
258
        ("HTTPS_PROXY", crate::egress::GUEST_PROXY),
259
        ("https_proxy", crate::egress::GUEST_PROXY),
260
        ("HTTP_PROXY", crate::egress::GUEST_PROXY),
261
        ("http_proxy", crate::egress::GUEST_PROXY),
262
        ("NO_PROXY", "localhost,127.0.0.1"),
263
        ("no_proxy", "localhost,127.0.0.1"),
264
    ] {
265
        if !set.iter().any(|s| s == k) {
266
            env.push(serde_json::json!([k, v]));
267
        }
268
    }
269
}
270
fc10d9e8 Andy McCall ↶ 271
/// What `flow-init` reads from `/job.json`: argv, env, and the mounts
272
/// with the device each disk mount is attached as (vdc onwards).
273
pub fn guest_spec(job: &Job) -> serde_json::Value {
274
    let mut next_disk = b'c';
275
    let mounts: Vec<serde_json::Value> = job
276
        .mounts
277
        .iter()
278
        .map(|m| match m.source {
279
            MountSource::Tmpfs => serde_json::json!({ "kind": "tmpfs", "target": m.target, "size_mib": m.size_mib }),
280
            MountSource::Disk => {
281
                let dev = format!("/dev/vd{}", next_disk as char);
282
                next_disk += 1;
283
                serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev })
284
            }
285
        })
286
        .collect();
287
    serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>(), "mounts": mounts })
288
}
289
e0918b88 Andy McCall 290
fn lines(r: impl Read, console: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
291
    let mut r = BufReader::new(r);
292
    let mut buf = Vec::new();
293
    loop {
294
        buf.clear();
295
        match r.read_until(b'\n', &mut buf) {
296
            Ok(0) | Err(_) => break,
297
            Ok(_) => {
298
                if buf.last() == Some(&b'\n') {
299
                    buf.pop();
300
                }
301
                if tx.send((console, buf.clone())).is_err() {
302
                    break;
303
                }
304
            }
305
        }
fc10d9e8 Andy McCall ↶ 306
    }
307
}
308
309
#[cfg(test)]
310
mod tests {
311
    use super::*;
312
    use flow_core::Mount;
ea81796b Andy McCall ↶ 313
314
    #[test]
315
    fn a_job_with_egress_gets_the_proxy_variables_unless_it_sets_them() {
316
        let mut spec = serde_json::json!({ "env": [["HTTPS_PROXY", "http://mine:1"]] });
317
        super::add_proxy_env(&mut spec);
318
        let env = spec["env"].as_array().unwrap();
319
        let get = |k: &str| env.iter().find(|kv| kv[0] == k).map(|kv| kv[1].as_str().unwrap().to_string());
320
        assert_eq!(get("HTTPS_PROXY").as_deref(), Some("http://mine:1"), "the job's own is kept");
321
        assert_eq!(get("https_proxy").as_deref(), Some("http://127.0.0.1:3128"));
322
        assert_eq!(get("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
323
    }
fc10d9e8 Andy McCall ↶ 324
325
    #[test]
326
    fn guest_spec_numbers_disks_after_the_workspace() {
327
        let mut job: Job = serde_json::from_value(serde_json::json!({
328
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"]
329
        }))
330
        .unwrap();
331
        assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts");
332
        job.mounts = vec![
d60aac6c Andy McCall ↶ 333
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
334
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
335
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
fc10d9e8 Andy McCall ↶ 336
        ];
337
        let spec = guest_spec(&job);
338
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
339
        assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256}));
340
        assert_eq!(spec["mounts"][2]["device"], "/dev/vdd");
341
        assert_eq!(spec["env"], serde_json::json!([["A", "1"]]));
e0918b88 Andy McCall 342
    }
343
}