OXYGENHUB
# eb8e445f27b6 oxygenflow / crates / flow / src / wasm.rs
14.6 KiB · Rust · last changed The WASM tier: WASI modules run by wasmtime on any runner (closes #75) 10 days ago source blame history raw
1
//! The WASM tier (spec §4.1, oxygenflow#75): a job's `exec` names a WASI
2
//! preview 1 module, or a glob of modules run one after another, which
3
//! wasmtime runs with the job's workspace at `/src` and nothing else: no
4
//! network, no other file of the runner. Every runner can offer it, and it
5
//! is the tier phones and browsers run (docs/handover.md).
6
//!
7
//! As in the syscall tier the module runs in a child of the runner
8
//! (`flow __wasm SPEC`), so the runner streams its output as it comes,
9
//! stops it at the time limit or when the run is cancelled, and measures
10
//! its memory the same way; wasmtime itself also holds it to the sandbox's
11
//! memory limit.
12
13
use crate::microvm::{Outcome, Sink};
14
use crate::syscall::{lines, self_hash, FAILED};
15
use anyhow::{bail, Context, Result};
16
use flow_core::api::Record;
17
use flow_core::{Environment, Job, MountSource, Sandbox};
18
use serde::{Deserialize, Serialize};
19
use std::path::{Path, PathBuf};
20
use std::sync::mpsc;
21
use std::time::{Duration, Instant};
22
23
/// The runtime, named in every receipt this tier writes.
24
pub const WASMTIME: &str = "49.0.4";
25
26
/// A line the helper writes to say something about the job itself (which
27
/// module starts), shown as the runner's own note rather than the job's.
28
const NOTE: &str = "\u{1e}N ";
29
30
/// The exit code for a module that trapped (crashed) rather than exiting.
31
const TRAPPED: i32 = 134;
32
33
/// What the helper runs, written by the runner for `flow __wasm`.
34
#[derive(Serialize, Deserialize)]
35
struct Spec {
36
    /// The job's files, preopened as `/src` (and as the current directory).
37
    workspace: PathBuf,
38
    /// The modules to run in turn, relative to the workspace.
39
    modules: Vec<String>,
40
    /// The arguments after the module.
41
    args: Vec<String>,
42
    env: Vec<(String, String)>,
43
    memory_mib: u32,
44
    /// The job's mounts: a directory of the runner and where the module sees it.
45
    preopens: Vec<(PathBuf, String)>,
46
}
47
48
/// The receipt environment of a job in this tier: the runtime and its
49
/// version, this runner's binary, the memory allowed, and every module run
50
/// with its hash, so the receipt says exactly what code ran.
51
pub fn environment(sandbox: &Sandbox, job: &Job, modules: &[(String, String)]) -> Environment {
52
    Environment {
53
        runtime: "wasm".into(),
54
        parts: vec![
55
            ("wasmtime".into(), WASMTIME.into()),
56
            ("flow".into(), self_hash().to_string()),
57
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
58
        ]
59
        .into_iter()
60
        .chain(modules.iter().map(|(path, hash)| (format!("module {path}"), hash.clone())))
61
        .collect(),
62
        rootfs: String::new(),
63
        argv: job.exec.clone(),
64
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
65
    }
66
}
67
68
/// The modules `program` names in `workspace`: one file, or the files a
69
/// glob matches (`*` in its last part only), sorted. A path may start with
70
/// `/src/`, where the module sees the workspace.
71
pub fn modules(workspace: &Path, program: &str) -> Result<Vec<String>> {
72
    let rel = program.strip_prefix("/src/").unwrap_or(program).trim_start_matches("./");
73
    if rel.is_empty() || rel.starts_with('/') || rel.split('/').any(|p| p == "..") {
74
        bail!("module {program:?}: give a path inside the job's files, such as target/wasm32-wasip1/debug/app.wasm");
75
    }
76
    let (dir, last) = rel.rsplit_once('/').unwrap_or(("", rel));
77
    if dir.contains('*') {
78
        bail!("module {program:?}: only the file name may hold `*`");
79
    }
80
    if !last.contains('*') {
81
        if !workspace.join(rel).is_file() {
82
            bail!("module {rel} is not among the job's files (its scope or an earlier job's artifact)");
83
        }
84
        return Ok(vec![rel.to_string()]);
85
    }
86
    let mut found: Vec<String> = std::fs::read_dir(workspace.join(dir))
87
        .with_context(|| format!("module {program:?}: the directory {dir} is not among the job's files"))?
88
        .filter_map(|e| e.ok())
89
        .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
90
        .filter_map(|e| e.file_name().into_string().ok())
91
        .filter(|name| wildcard(last, name))
92
        .map(|name| if dir.is_empty() { name } else { format!("{dir}/{name}") })
93
        .collect();
94
    found.sort();
95
    if found.is_empty() {
96
        bail!("no module matches {program:?}");
97
    }
98
    Ok(found)
99
}
100
101
/// Whether `name` matches `pattern`, where `*` stands for any run of characters.
102
fn wildcard(pattern: &str, name: &str) -> bool {
103
    let parts: Vec<&str> = pattern.split('*').collect();
104
    let (first, last) = (parts[0], parts[parts.len() - 1]);
105
    if parts.len() == 1 {
106
        return pattern == name;
107
    }
108
    if !name.starts_with(first) || !name[first.len()..].ends_with(last) || name.len() < first.len() + last.len() {
109
        return false;
110
    }
111
    let mut rest = &name[first.len()..name.len() - last.len()];
112
    for middle in &parts[1..parts.len() - 1] {
113
        match rest.find(middle) {
114
            Some(i) => rest = &rest[i + middle.len()..],
115
            None => return false,
116
        }
117
    }
118
    true
119
}
120
121
/// Run `job` in this tier with its files in `workspace` (as the syscall
122
/// tier lays them out); `scratch` holds the helper's spec and the job's
123
/// mounts.
124
pub fn run(sandbox: &Sandbox, job: &Job, workspace: &Path, scratch: &Path, timeout: Duration, sink: &mut dyn Sink) -> Result<Outcome> {
125
    if !job.secrets.is_empty() || !job.identities.is_empty() {
126
        bail!("job {}: the WASM tier takes no secrets or identities yet; run it in a microvm or syscall sandbox", job.name);
127
    }
128
    let Some(program) = job.exec.first() else { bail!("job {}: exec names no module", job.name) };
129
    let modules = modules(workspace, program)?;
130
    let hashed: Vec<(String, String)> =
131
        modules.iter().map(|m| Ok((m.clone(), crate::home::hash_file(&workspace.join(m))?))).collect::<Result<_>>()?;
132
    let environment = environment(sandbox, job, &hashed);
133
    let mut preopens = Vec::new();
134
    let mut disks = 0;
135
    for (i, m) in job.mounts.iter().enumerate() {
136
        let dir = match m.source {
137
            MountSource::Disk => {
138
                disks += 1;
139
                crate::syscall::disk_dir(scratch, disks - 1)
140
            }
141
            MountSource::Tmpfs => scratch.join(format!("tmpfs-{i}")),
142
        };
143
        std::fs::create_dir_all(&dir)?;
144
        preopens.push((dir, m.target.clone()));
145
    }
146
    let spec = Spec {
147
        workspace: workspace.to_path_buf(),
148
        modules,
149
        args: job.exec[1..].to_vec(),
150
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
151
        memory_mib: sandbox.memory_mib,
152
        preopens,
153
    };
154
    let spec_path = scratch.join("wasm.json");
155
    std::fs::write(&spec_path, serde_json::to_vec(&spec)?)?;
156
157
    let started = Instant::now();
158
    let mut child = std::process::Command::new("/proc/self/exe")
159
        .arg("__wasm")
160
        .arg(&spec_path)
161
        .env_clear()
162
        .current_dir(scratch)
163
        .stdin(std::process::Stdio::null())
164
        .stdout(std::process::Stdio::piped())
165
        .stderr(std::process::Stdio::piped())
166
        .spawn()
167
        .context("start the WASM helper")?;
168
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
169
    let out = child.stdout.take().unwrap();
170
    let err = child.stderr.take().unwrap();
171
    let tx2 = tx.clone();
172
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
173
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
174
    let (mut timed_out, mut cancelled, mut failed_to_start) = (false, false, false);
175
    let mut peak_memory_mib = None;
176
    let deadline = started + timeout;
177
    loop {
178
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
179
            peak_memory_mib = Some(m);
180
        }
181
        match rx.recv_timeout(Duration::from_millis(200)) {
182
            Ok((stdout, line)) => {
183
                let text = String::from_utf8_lossy(&line).into_owned();
184
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
185
                if let Some(note) = text.strip_prefix(NOTE).filter(|_| !stdout) {
186
                    sink.record(rec("flow", note));
187
                } else if let Some(why) = text.strip_prefix(FAILED).filter(|_| !stdout) {
188
                    failed_to_start = true;
189
                    sink.record(rec("flow", &format!("the WASM tier could not run the job: {why}")));
190
                } else {
191
                    sink.record(rec(if stdout { "out" } else { "err" }, &text));
192
                }
193
            }
194
            Err(mpsc::RecvTimeoutError::Timeout) => {}
195
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
196
        }
197
        if !timed_out && !cancelled && sink.cancelled() {
198
            cancelled = true;
199
            let _ = child.kill();
200
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
201
        }
202
        if Instant::now() >= deadline && !timed_out && !cancelled {
203
            timed_out = true;
204
            let _ = child.kill();
205
            sink.record(Record {
206
                t: t(&started),
207
                stream: "flow".into(),
208
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
209
            });
210
        }
211
    }
212
    let status = child.wait()?;
213
    for r in readers {
214
        let _ = r.join();
215
    }
216
    let _ = std::fs::remove_file(&spec_path);
217
    let exit_code = status.code();
218
    if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start {
219
        sink.record(Record {
220
            t: t(&started),
221
            stream: "flow".into(),
222
            text: format!("the WASM helper ended without an exit code ({status})"),
223
        });
224
    }
225
    Ok(Outcome {
226
        exit_code: if timed_out || cancelled { None } else { exit_code },
227
        duration_ms: t(&started),
228
        environment,
229
        timed_out,
230
        cancelled,
231
        peak_memory_mib,
232
        pruned: Vec::new(),
233
    })
234
}
235
236
/// `flow __wasm SPEC`: run the spec's modules in turn, each in a fresh
237
/// instance; the exit code is the first non-zero one, after all have run,
238
/// so one failing module does not hide another's results.
239
pub fn wasm_main(spec_path: &Path) -> i32 {
240
    match helper(spec_path) {
241
        Ok(code) => code,
242
        Err(e) => {
243
            eprintln!("{FAILED}{e:#}");
244
            125
245
        }
246
    }
247
}
248
249
/// wasmtime's own error, as this crate's: with its whole chain of causes.
250
fn ww<T>(r: wasmtime::Result<T>, what: impl FnOnce() -> String) -> Result<T> {
251
    r.map_err(|e| anyhow::anyhow!("{}: {e:#}", what()))
252
}
253
254
struct State {
255
    wasi: wasmtime_wasi::p1::WasiP1Ctx,
256
    limits: wasmtime::StoreLimits,
257
}
258
259
fn helper(spec_path: &Path) -> Result<i32> {
260
    let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path)?).context("read the spec")?;
261
    let engine = ww(wasmtime::Engine::new(&wasmtime::Config::new()), || "start wasmtime".into())?;
262
    let mut linker: wasmtime::Linker<State> = wasmtime::Linker::new(&engine);
263
    ww(wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s: &mut State| &mut s.wasi), || "link WASI".into())?;
264
    let mut first_failure = 0;
265
    for path in &spec.modules {
266
        let code = run_module(&engine, &linker, &spec, path)?;
267
        if spec.modules.len() > 1 {
268
            eprintln!("{NOTE}{path}: exit {code}");
269
        }
270
        if first_failure == 0 && code != 0 {
271
            first_failure = code;
272
        }
273
    }
274
    Ok(first_failure)
275
}
276
277
fn run_module(engine: &wasmtime::Engine, linker: &wasmtime::Linker<State>, spec: &Spec, path: &str) -> Result<i32> {
278
    use wasmtime_wasi::{FsPerms, WasiCtxBuilder};
279
    if spec.modules.len() > 1 {
280
        eprintln!("{NOTE}running {path}");
281
    }
282
    let module = ww(wasmtime::Module::from_file(engine, spec.workspace.join(path)), || format!("module {path} does not load"))?;
283
    let name = path.rsplit('/').next().unwrap_or(path);
284
    let mut argv = vec![name.to_string()];
285
    argv.extend(spec.args.iter().cloned());
286
    let mut wasi = WasiCtxBuilder::new();
287
    wasi.inherit_stdout().inherit_stderr().args(&argv).envs(&spec.env).env("PWD", "/src").allow_blocking_current_thread(true);
288
    let open = |wasi: &mut WasiCtxBuilder, dir: &Path, at: &str| {
289
        ww(wasi.preopened_dir(dir, at, FsPerms::ReadWrite).map(|_| ()), || format!("open {} as {at}", dir.display()))
290
    };
291
    open(&mut wasi, &spec.workspace, "/src")?;
292
    // Relative paths resolve against the workspace too.
293
    open(&mut wasi, &spec.workspace, ".")?;
294
    for (dir, target) in &spec.preopens {
295
        open(&mut wasi, dir, target)?;
296
    }
297
    let limits = wasmtime::StoreLimitsBuilder::new().memory_size(spec.memory_mib as usize * 1024 * 1024).build();
298
    let mut store = wasmtime::Store::new(engine, State { wasi: wasi.build_p1(), limits });
299
    store.limiter(|s| &mut s.limits);
300
    let instance = ww(linker.instantiate(&mut store, &module), || format!("module {path} cannot start here"))?;
301
    let start =
302
        ww(instance.get_typed_func::<(), ()>(&mut store, "_start"), || format!("module {path} has no _start: is it a WASI command?"))?;
303
    Ok(match start.call(&mut store, ()) {
304
        Ok(()) => 0,
305
        Err(e) => match e.downcast_ref::<wasmtime_wasi::I32Exit>() {
306
            Some(exit) => exit.0,
307
            None => {
308
                eprintln!("{NOTE}{path} trapped: {e:#}");
309
                TRAPPED
310
            }
311
        },
312
    })
313
}
314
315
#[cfg(test)]
316
mod tests {
317
    use super::*;
318
319
    #[test]
320
    fn globs_match_names_with_stars() {
321
        assert!(wildcard("*.wasm", "oxy_core-1a2b.wasm"));
322
        assert!(wildcard("oxy_core-*.wasm", "oxy_core-1a2b.wasm"));
323
        assert!(!wildcard("oxy_core-*.wasm", "oxy_core-1a2b.d"));
324
        assert!(wildcard("a*b*c", "aXbYc") && !wildcard("a*b*c", "aXcYb"));
325
        assert!(wildcard("plain.wasm", "plain.wasm") && !wildcard("plain.wasm", "other.wasm"));
326
    }
327
328
    #[test]
329
    fn modules_are_files_of_the_job() {
330
        let dir = tempfile::tempdir().unwrap();
331
        let deps = dir.path().join("target/wasm32-wasip1/debug/deps");
332
        std::fs::create_dir_all(&deps).unwrap();
333
        for f in ["b-2.wasm", "a-1.wasm", "a-1.d"] {
334
            std::fs::write(deps.join(f), b"").unwrap();
335
        }
336
        let ws = dir.path();
337
        assert_eq!(
338
            modules(ws, "/src/target/wasm32-wasip1/debug/deps/*.wasm").unwrap(),
339
            ["target/wasm32-wasip1/debug/deps/a-1.wasm", "target/wasm32-wasip1/debug/deps/b-2.wasm"]
340
        );
341
        assert_eq!(modules(ws, "./target/wasm32-wasip1/debug/deps/b-2.wasm").unwrap(), ["target/wasm32-wasip1/debug/deps/b-2.wasm"]);
342
        assert!(modules(ws, "target/wasm32-wasip1/debug/deps/*.so").unwrap_err().to_string().contains("no module matches"));
343
        assert!(modules(ws, "../etc/passwd").is_err());
344
        assert!(modules(ws, "/etc/x.wasm").is_err());
345
        assert!(modules(ws, "target/*/debug/a.wasm").unwrap_err().to_string().contains("only the file name"));
346
    }
347
}