1use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ExecSpec {
20 pub image: Option<String>,
22 pub command: Vec<String>,
24 pub workdir: Option<String>,
26 pub env: Vec<(String, String)>,
28 pub timeout_ms: u64,
30}
31
32impl ExecSpec {
33 pub fn command(command: Vec<String>) -> Self {
34 Self {
35 image: None,
36 command,
37 workdir: None,
38 env: Vec::new(),
39 timeout_ms: 60_000,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ExecOutput {
47 pub exit_code: i32,
48 pub stdout: String,
49 pub stderr: String,
50}
51
52#[derive(Debug, Clone)]
54pub struct SandboxHandle {
55 pub id: String,
56}
57
58#[derive(Debug, Error)]
59pub enum SandboxError {
60 #[error("sandbox spawn failed: {0}")]
61 Spawn(String),
62 #[error("sandbox exec failed: {0}")]
63 Exec(String),
64 #[error("sandbox not configured: {0}")]
65 NotConfigured(String),
66 #[error("io error: {0}")]
67 Io(#[from] std::io::Error),
68}
69
70#[async_trait]
72pub trait Sandbox: Send + Sync {
73 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError>;
75 async fn exec(
77 &self,
78 handle: &SandboxHandle,
79 cmd: &[String],
80 ) -> Result<ExecOutput, SandboxError>;
81 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError>;
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SandboxProvider {
88 Docker,
89 Kata,
90 Cube,
91}
92
93impl SandboxProvider {
94 pub fn parse(s: &str) -> Option<Self> {
95 match s.to_ascii_lowercase().as_str() {
96 "docker" => Some(SandboxProvider::Docker),
97 "kata" => Some(SandboxProvider::Kata),
98 "cube" => Some(SandboxProvider::Cube),
99 _ => None,
100 }
101 }
102
103 pub fn as_str(&self) -> &'static str {
104 match self {
105 SandboxProvider::Docker => "docker",
106 SandboxProvider::Kata => "kata",
107 SandboxProvider::Cube => "cube",
108 }
109 }
110}
111
112struct CliSandbox {
116 provider: SandboxProvider,
117 runner: String,
118 run_args: Vec<String>,
119 default_image: String,
120}
121
122impl CliSandbox {
123 fn new(
124 provider: SandboxProvider,
125 runner: &str,
126 run_args: Vec<String>,
127 default_image: &str,
128 ) -> Self {
129 Self {
130 provider,
131 runner: runner.to_string(),
132 run_args,
133 default_image: default_image.to_string(),
134 }
135 }
136
137 fn image_of(&self, spec: &ExecSpec) -> String {
138 spec.image
139 .clone()
140 .unwrap_or_else(|| self.default_image.clone())
141 }
142}
143
144#[async_trait]
145impl Sandbox for CliSandbox {
146 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
147 let image = self.image_of(spec);
148 let mut cmd = tokio::process::Command::new(&self.runner);
150 cmd.args(&self.run_args).arg(&image).args(["sleep", "3600"]);
151 let out = cmd
152 .output()
153 .await
154 .map_err(|e| SandboxError::Spawn(e.to_string()))?;
155 if !out.status.success() {
156 return Err(SandboxError::Spawn(
157 String::from_utf8_lossy(&out.stderr).to_string(),
158 ));
159 }
160 let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
161 Ok(SandboxHandle { id })
162 }
163
164 async fn exec(
165 &self,
166 handle: &SandboxHandle,
167 cmd: &[String],
168 ) -> Result<ExecOutput, SandboxError> {
169 if cmd.is_empty() {
170 return Err(SandboxError::Exec("empty command".into()));
171 }
172 let joined = shell_join(cmd);
173 let mut command = tokio::process::Command::new(&self.runner);
174 command
175 .arg("exec")
176 .arg(&handle.id)
177 .args(["sh", "-c", &joined]);
178 let out = command
179 .output()
180 .await
181 .map_err(|e| SandboxError::Exec(e.to_string()))?;
182 Ok(ExecOutput {
183 exit_code: out.status.code().unwrap_or(-1),
184 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
185 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
186 })
187 }
188
189 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
190 let mut command = tokio::process::Command::new(&self.runner);
191 command.arg("rm").arg("-f").arg(&handle.id);
192 let out = command
193 .output()
194 .await
195 .map_err(|e| SandboxError::Exec(e.to_string()))?;
196 if !out.status.success() {
197 tracing::warn!(
198 provider = self.provider.as_str(),
199 stderr = %String::from_utf8_lossy(&out.stderr),
200 "sandbox destroy reported a non-zero status"
201 );
202 }
203 Ok(())
204 }
205}
206
207pub struct DockerSandbox {
209 inner: CliSandbox,
210}
211
212impl DockerSandbox {
213 pub fn new() -> Self {
214 Self {
215 inner: CliSandbox::new(
216 SandboxProvider::Docker,
217 "docker",
218 vec!["run".into(), "--rm".into(), "-d".into()],
219 "alpine:latest",
220 ),
221 }
222 }
223}
224
225#[async_trait]
226impl Sandbox for DockerSandbox {
227 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
228 self.inner.spawn(spec).await
229 }
230 async fn exec(
231 &self,
232 handle: &SandboxHandle,
233 cmd: &[String],
234 ) -> Result<ExecOutput, SandboxError> {
235 self.inner.exec(handle, cmd).await
236 }
237 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
238 self.inner.destroy(handle).await
239 }
240}
241
242pub struct KataSandbox {
244 inner: CliSandbox,
245}
246
247impl KataSandbox {
248 pub fn new() -> Self {
249 Self {
250 inner: CliSandbox::new(
251 SandboxProvider::Kata,
252 "docker",
253 vec![
254 "run".into(),
255 "--runtime".into(),
256 "kata".into(),
257 "--rm".into(),
258 "-d".into(),
259 ],
260 "alpine:latest",
261 ),
262 }
263 }
264}
265
266#[async_trait]
267impl Sandbox for KataSandbox {
268 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
269 self.inner.spawn(spec).await
270 }
271 async fn exec(
272 &self,
273 handle: &SandboxHandle,
274 cmd: &[String],
275 ) -> Result<ExecOutput, SandboxError> {
276 self.inner.exec(handle, cmd).await
277 }
278 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
279 self.inner.destroy(handle).await
280 }
281}
282
283pub struct CubeSandbox {
286 inner: CliSandbox,
287}
288
289impl CubeSandbox {
290 pub fn new() -> Self {
291 Self {
292 inner: CliSandbox::new(
293 SandboxProvider::Cube,
294 "cube",
295 vec!["sandbox".into(), "run".into(), "--rm".into()],
297 "cube-image:latest",
298 ),
299 }
300 }
301}
302
303#[async_trait]
304impl Sandbox for CubeSandbox {
305 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
306 self.inner.spawn(spec).await
307 }
308 async fn exec(
309 &self,
310 handle: &SandboxHandle,
311 cmd: &[String],
312 ) -> Result<ExecOutput, SandboxError> {
313 self.inner.exec(handle, cmd).await
314 }
315 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
316 self.inner.destroy(handle).await
317 }
318}
319
320impl Default for DockerSandbox {
321 fn default() -> Self {
322 Self::new()
323 }
324}
325
326impl Default for KataSandbox {
327 fn default() -> Self {
328 Self::new()
329 }
330}
331
332impl Default for CubeSandbox {
333 fn default() -> Self {
334 Self::new()
335 }
336}
337
338pub fn default_sandbox() -> Box<dyn Sandbox> {
340 Box::new(DockerSandbox::new())
341}
342
343pub fn from_provider(provider: SandboxProvider) -> Box<dyn Sandbox> {
345 match provider {
346 SandboxProvider::Docker => Box::new(DockerSandbox::new()),
347 SandboxProvider::Kata => Box::new(KataSandbox::new()),
348 SandboxProvider::Cube => Box::new(CubeSandbox::new()),
349 }
350}
351
352fn shell_join(cmd: &[String]) -> String {
353 cmd.iter()
354 .map(|a| {
355 if a.contains(char::is_whitespace) || a.contains('"') || a.contains('\'') {
356 format!("'{}'", a.replace('\'', "'\\''"))
357 } else {
358 a.clone()
359 }
360 })
361 .collect::<Vec<_>>()
362 .join(" ")
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn default_is_docker() {
371 let s = default_sandbox();
372 let _ = s;
374 }
375
376 #[test]
377 fn provider_parsing() {
378 assert_eq!(
379 SandboxProvider::parse("docker"),
380 Some(SandboxProvider::Docker)
381 );
382 assert_eq!(SandboxProvider::parse("KATA"), Some(SandboxProvider::Kata));
383 assert_eq!(SandboxProvider::parse("cube"), Some(SandboxProvider::Cube));
384 assert_eq!(SandboxProvider::parse("podman"), None);
385 let _ = uuid::Uuid::new_v4();
386 }
387
388 #[test]
389 fn shell_join_quotes() {
390 assert_eq!(
391 shell_join(&["echo".into(), "hello world".into()]),
392 "echo 'hello world'"
393 );
394 }
395
396 #[test]
397 fn exec_spec_command_defaults() {
398 let s = ExecSpec::command(vec!["echo".into(), "hi".into()]);
399 assert_eq!(s.command, vec!["echo", "hi"]);
400 assert_eq!(s.timeout_ms, 60_000);
401 assert!(s.image.is_none());
402 assert!(s.workdir.is_none());
403 assert!(s.env.is_empty());
404 }
405
406 #[test]
407 fn shell_join_empty_and_escapes() {
408 assert_eq!(shell_join(&[]), "");
409 assert_eq!(shell_join(&["a\"b".into()]), "'a\"b'");
411 assert_eq!(shell_join(&["it's".into()]), "'it'\\''s'");
413 }
414
415 #[test]
416 fn provider_as_str_roundtrip() {
417 for p in [
418 SandboxProvider::Docker,
419 SandboxProvider::Kata,
420 SandboxProvider::Cube,
421 ] {
422 let s = p.as_str();
423 assert_eq!(SandboxProvider::parse(s), Some(p));
424 }
425 }
426
427 #[test]
428 fn provider_parse_is_case_insensitive_and_rejects_unknown() {
429 assert_eq!(
430 SandboxProvider::parse("DOCKER"),
431 Some(SandboxProvider::Docker)
432 );
433 assert_eq!(SandboxProvider::parse("Kata"), Some(SandboxProvider::Kata));
434 assert_eq!(SandboxProvider::parse("podman"), None);
435 assert_eq!(SandboxProvider::parse(""), None);
436 }
437
438 #[test]
439 fn from_provider_builds_all_variants() {
440 for p in [
441 SandboxProvider::Docker,
442 SandboxProvider::Kata,
443 SandboxProvider::Cube,
444 ] {
445 let _ = from_provider(p); }
447 let _ = CubeSandbox::new();
448 let _ = KataSandbox::new();
449 }
450}