| 28cc603f Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 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 |
|
| 24 |
pub const WASMTIME: &str = "49.0.4"; |
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
const NOTE: &str = "\u{1e}N "; |
| 29 |
|
| 30 |
|
| 31 |
const TRAPPED: i32 = 134; |
| 32 |
|
| 33 |
|
| 34 |
#[derive(Serialize, Deserialize)] |
| 35 |
struct Spec { |
| 36 |
|
| 37 |
workspace: PathBuf, |
| 38 |
|
| 39 |
modules: Vec<String>, |
| 40 |
|
| 41 |
args: Vec<String>, |
| 42 |
env: Vec<(String, String)>, |
| 43 |
memory_mib: u32, |
| 44 |
|
| 45 |
preopens: Vec<(PathBuf, String)>, |
| 46 |
} |
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 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 |
|
| 69 |
|
| 70 |
|
| 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 |
|
| 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 |
|
| 122 |
|
| 123 |
|
| 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 |
|
| 237 |
|
| 238 |
|
| 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 |
|
| 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 |
|
| 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 |
} |