Skip to main content

tatara_engine/drivers/
wasi.rs

1//! WASI driver — runs WASM/WASI components as tatara workloads via wasmtime.
2//!
3//! Phase 1: subprocess approach (wasmtime CLI).
4//! Phase 2: embedded wasmtime (library) with host functions.
5//!
6//! The complete sandwich:
7//! - eBPF hooks the kernel boundary (below)
8//! - WASI standardizes the userspace boundary (above)
9//! - Together every system call is observable and controllable
10
11use anyhow::{bail, Context, Result};
12use async_trait::async_trait;
13use chrono::Utc;
14use std::path::Path;
15use std::time::Duration;
16use tokio::process::Command;
17use tokio::sync::mpsc;
18use tracing::{debug, error, info, warn};
19
20use super::{Driver, LogEntry, TaskHandle};
21use tatara_core::domain::allocation::TaskRunState;
22use tatara_core::domain::job::{DriverType, Task, TaskConfig};
23
24/// WASI driver — runs WASM/WASI components via wasmtime subprocess.
25///
26/// Detects wasmtime on PATH. Maps WasiCapabilities to --wasi flags.
27/// Log capture via stdout/stderr files (same pattern as ExecDriver).
28/// Process lifecycle via PID (same pattern as NixDriver).
29pub struct WasiDriver;
30
31#[async_trait]
32impl Driver for WasiDriver {
33    fn name(&self) -> &str {
34        "wasi"
35    }
36
37    async fn available(&self) -> bool {
38        Command::new("wasmtime")
39            .arg("--version")
40            .output()
41            .await
42            .map(|o| o.status.success())
43            .unwrap_or(false)
44    }
45
46    async fn start(&self, task: &Task, alloc_dir: &Path) -> Result<TaskHandle> {
47        let (wasm_path, capabilities, mounts, allowed_services) = match &task.config {
48            TaskConfig::Wasi {
49                wasm_path,
50                capabilities,
51                mounts,
52                allowed_services,
53            } => (wasm_path, capabilities, mounts, allowed_services),
54            _ => bail!("WasiDriver received non-WASI task config"),
55        };
56
57        let mut cmd = Command::new("wasmtime");
58        cmd.arg("run");
59
60        // Map capabilities to --wasi flags (WASI Preview 2 inherit model)
61        if capabilities.network {
62            cmd.args(["--wasi", "inherit-network"]);
63        }
64        if capabilities.filesystem {
65            cmd.args(["--wasi", "inherit-filesystem"]);
66        }
67        if capabilities.clocks {
68            cmd.args(["--wasi", "inherit-clocks"]);
69        }
70        if capabilities.random {
71            cmd.args(["--wasi", "inherit-random"]);
72        }
73        if capabilities.stdout || capabilities.stderr {
74            cmd.args(["--wasi", "inherit-stdio"]);
75        }
76
77        // Filesystem mounts as --dir flags
78        for (host_path, guest_path) in mounts {
79            cmd.args(["--dir", &format!("{host_path}::{guest_path}")]);
80        }
81
82        // Environment variables
83        for (k, v) in &task.env {
84            cmd.args(["--env", &format!("{k}={v}")]);
85        }
86
87        // Fuel metering for resource limits (heuristic: cpu_mhz * 1M instructions)
88        if task.resources.cpu_mhz > 0 {
89            let fuel = task.resources.cpu_mhz * 1_000_000;
90            cmd.args(["--fuel", &fuel.to_string()]);
91        }
92
93        // The WASM component to run
94        cmd.arg(wasm_path);
95
96        // Log capture to allocation directory
97        let log_dir = alloc_dir.join(&task.name);
98        tokio::fs::create_dir_all(&log_dir)
99            .await
100            .context("Failed to create WASI log directory")?;
101
102        let stdout_file = std::fs::File::create(log_dir.join("stdout.log"))
103            .context("Failed to create stdout log")?;
104        let stderr_file = std::fs::File::create(log_dir.join("stderr.log"))
105            .context("Failed to create stderr log")?;
106
107        cmd.stdout(stdout_file);
108        cmd.stderr(stderr_file);
109        cmd.kill_on_drop(false);
110
111        info!(
112            task = %task.name,
113            wasm_path = %wasm_path,
114            network = capabilities.network,
115            filesystem = capabilities.filesystem,
116            "starting WASI component"
117        );
118
119        let child = cmd.spawn().context("Failed to spawn wasmtime")?;
120        let pid = child.id();
121
122        // Detach the child process (tatara manages lifecycle via PID)
123        std::mem::forget(child);
124
125        Ok(TaskHandle {
126            driver: DriverType::Wasi,
127            pid,
128            container_id: None,
129            started_at: Utc::now(),
130        })
131    }
132
133    async fn stop(&self, handle: &TaskHandle, timeout: Duration) -> Result<()> {
134        let Some(pid) = handle.pid else {
135            return Ok(());
136        };
137
138        #[cfg(unix)]
139        {
140            use nix::sys::signal::{kill, Signal};
141            use nix::unistd::Pid;
142
143            let pid = Pid::from_raw(pid as i32);
144
145            // SIGTERM first (graceful)
146            if kill(pid, Signal::SIGTERM).is_err() {
147                return Ok(()); // Process already gone
148            }
149
150            // Wait for process to exit
151            let deadline = tokio::time::Instant::now() + timeout;
152            loop {
153                if kill(pid, None).is_err() {
154                    return Ok(()); // Process exited
155                }
156                if tokio::time::Instant::now() >= deadline {
157                    // Force kill
158                    let _ = kill(pid, Signal::SIGKILL);
159                    return Ok(());
160                }
161                tokio::time::sleep(Duration::from_millis(100)).await;
162            }
163        }
164
165        #[cfg(not(unix))]
166        {
167            warn!("WASI process signal management not supported on this platform");
168            Ok(())
169        }
170    }
171
172    async fn status(&self, handle: &TaskHandle) -> Result<TaskRunState> {
173        let Some(pid) = handle.pid else {
174            return Ok(TaskRunState::Dead);
175        };
176
177        #[cfg(unix)]
178        {
179            use nix::sys::signal::kill;
180            use nix::unistd::Pid;
181
182            let pid = Pid::from_raw(pid as i32);
183            if kill(pid, None).is_ok() {
184                Ok(TaskRunState::Running)
185            } else {
186                Ok(TaskRunState::Dead)
187            }
188        }
189
190        #[cfg(not(unix))]
191        {
192            Ok(TaskRunState::Dead)
193        }
194    }
195
196    async fn logs(&self, handle: &TaskHandle) -> Result<mpsc::Receiver<LogEntry>> {
197        // Same pattern as ExecDriver — tail log files
198        let (tx, rx) = mpsc::channel(256);
199
200        // Log streaming is handled by the LogCollector which reads
201        // stdout.log/stderr.log from the allocation directory.
202        // This method returns an empty channel; real streaming happens
203        // via the /api/v1/allocations/{id}/logs endpoint.
204        tokio::spawn(async move {
205            let _ = tx;
206            // Log tailing handled by LogCollector
207        });
208
209        Ok(rx)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[tokio::test]
218    async fn test_wasi_driver_name() {
219        let driver = WasiDriver;
220        assert_eq!(driver.name(), "wasi");
221    }
222
223    #[tokio::test]
224    async fn test_wasi_driver_available() {
225        let driver = WasiDriver;
226        // May or may not be available depending on environment
227        let _ = driver.available().await;
228    }
229}