1use async_trait::async_trait;
25use bollard::container::{
26 Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
27};
28use bollard::exec::{CreateExecOptions, StartExecResults};
29use bollard::{Docker, API_DEFAULT_VERSION};
30use serde::{Deserialize, Serialize};
31use std::env;
32use thiserror::Error;
33use tokio::sync::OnceCell;
34
35#[derive(Debug, Clone, Copy)]
43pub struct SandboxResourceLimits {
44 pub cpus: f64,
46 pub memory_bytes: i64,
48 pub pids_limit: i64,
50}
51
52impl SandboxResourceLimits {
53 pub fn from_env() -> Self {
56 Self {
57 cpus: parse_cpu(env::var("SANDBOX_CPUS").ok(), 1.0),
58 memory_bytes: parse_memory(env::var("SANDBOX_MEMORY").ok(), 512 * 1024 * 1024),
59 pids_limit: parse_pids(env::var("SANDBOX_PIDS_LIMIT").ok(), 256),
60 }
61 }
62}
63
64pub fn build_host_config(
70 res: &SandboxResourceLimits,
71 runtime: Option<&str>,
72) -> bollard::service::HostConfig {
73 bollard::service::HostConfig {
74 nano_cpus: Some((res.cpus * 1e9).round() as i64),
75 memory: Some(res.memory_bytes),
76 memory_swap: Some(-1),
77 pids_limit: Some(res.pids_limit),
78 runtime: runtime.map(|s| s.to_string()),
79 ..Default::default()
80 }
81}
82
83fn parse_cpu(raw: Option<String>, default: f64) -> f64 {
84 match raw {
85 Some(s) => match s.trim().parse::<f64>() {
86 Ok(v) if v > 0.0 => v,
87 _ => {
88 tracing::warn!(value = %s.trim(), default, "invalid SANDBOX_CPUS; using default");
89 default
90 }
91 },
92 None => default,
93 }
94}
95
96fn parse_memory(raw: Option<String>, default: i64) -> i64 {
97 match raw {
98 Some(s) => match parse_size(&s) {
99 Some(v) => v,
100 None => {
101 tracing::warn!(value = %s.trim(), default, "invalid SANDBOX_MEMORY; using default");
102 default
103 }
104 },
105 None => default,
106 }
107}
108
109fn parse_pids(raw: Option<String>, default: i64) -> i64 {
110 match raw {
111 Some(s) => match s.trim().parse::<i64>() {
112 Ok(v) if v > 0 => v,
113 _ => {
114 tracing::warn!(value = %s.trim(), default, "invalid SANDBOX_PIDS_LIMIT; using default");
115 default
116 }
117 },
118 None => default,
119 }
120}
121
122fn parse_size(s: &str) -> Option<i64> {
126 let s = s.trim();
127 let split = s
128 .find(|c: char| !c.is_ascii_digit() && c != '.')
129 .unwrap_or(s.len());
130 let (num, unit) = s.split_at(split);
131 let value: f64 = num.trim().parse().ok()?;
132 let mult: i64 = match unit.trim().to_ascii_lowercase().as_str() {
133 "" | "b" => 1,
134 "k" => 1024,
135 "m" => 1024 * 1024,
136 "g" => 1024 * 1024 * 1024,
137 "t" => 1024 * 1024 * 1024 * 1024,
138 _ => return None,
139 };
140 if value <= 0.0 {
141 return None;
142 }
143 Some((value * mult as f64) as i64)
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ExecSpec {
149 pub image: Option<String>,
151 pub command: Vec<String>,
153 pub workdir: Option<String>,
155 pub env: Vec<(String, String)>,
157 pub timeout_ms: u64,
159}
160
161impl ExecSpec {
162 pub fn command(command: Vec<String>) -> Self {
163 Self {
164 image: None,
165 command,
166 workdir: None,
167 env: Vec::new(),
168 timeout_ms: 60_000,
169 }
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ExecOutput {
176 pub exit_code: i32,
177 pub stdout: String,
178 pub stderr: String,
179}
180
181#[derive(Debug, Clone)]
183pub struct SandboxHandle {
184 pub id: String,
185}
186
187#[derive(Debug, Error)]
188pub enum SandboxError {
189 #[error("sandbox spawn failed: {0}")]
190 Spawn(String),
191 #[error("sandbox exec failed: {0}")]
192 Exec(String),
193 #[error("sandbox not configured: {0}")]
194 NotConfigured(String),
195 #[error("io error: {0}")]
196 Io(#[from] std::io::Error),
197}
198
199#[async_trait]
201pub trait Sandbox: Send + Sync {
202 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError>;
204 async fn exec(
206 &self,
207 handle: &SandboxHandle,
208 cmd: &[String],
209 ) -> Result<ExecOutput, SandboxError>;
210 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError>;
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum SandboxProvider {
217 Docker,
218 Kata,
219 Cube,
220 Codex,
223}
224
225impl SandboxProvider {
226 pub fn parse(s: &str) -> Option<Self> {
227 match s.to_ascii_lowercase().as_str() {
228 "docker" => Some(SandboxProvider::Docker),
229 "kata" => Some(SandboxProvider::Kata),
230 "cube" => Some(SandboxProvider::Cube),
231 "codex" => Some(SandboxProvider::Codex),
232 _ => None,
233 }
234 }
235
236 pub fn as_str(&self) -> &'static str {
237 match self {
238 SandboxProvider::Docker => "docker",
239 SandboxProvider::Kata => "kata",
240 SandboxProvider::Cube => "cube",
241 SandboxProvider::Codex => "codex",
242 }
243 }
244}
245
246struct CliSandbox {
250 provider: SandboxProvider,
251 runner: String,
252 run_args: Vec<String>,
253 default_image: String,
254}
255
256impl CliSandbox {
257 fn new(
258 provider: SandboxProvider,
259 runner: &str,
260 run_args: Vec<String>,
261 default_image: &str,
262 ) -> Self {
263 Self {
264 provider,
265 runner: runner.to_string(),
266 run_args,
267 default_image: default_image.to_string(),
268 }
269 }
270
271 fn image_of(&self, spec: &ExecSpec) -> String {
272 spec.image
273 .clone()
274 .unwrap_or_else(|| self.default_image.clone())
275 }
276
277 #[cfg(test)]
279 fn run_args(&self) -> &[String] {
280 &self.run_args
281 }
282}
283
284#[async_trait]
285impl Sandbox for CliSandbox {
286 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
287 let image = self.image_of(spec);
288 let mut cmd = tokio::process::Command::new(&self.runner);
290 cmd.args(&self.run_args).arg(&image).args(["sleep", "3600"]);
291 let out = cmd
292 .output()
293 .await
294 .map_err(|e| SandboxError::Spawn(e.to_string()))?;
295 if !out.status.success() {
296 return Err(SandboxError::Spawn(
297 String::from_utf8_lossy(&out.stderr).to_string(),
298 ));
299 }
300 let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
301 Ok(SandboxHandle { id })
302 }
303
304 async fn exec(
305 &self,
306 handle: &SandboxHandle,
307 cmd: &[String],
308 ) -> Result<ExecOutput, SandboxError> {
309 if cmd.is_empty() {
310 return Err(SandboxError::Exec("empty command".into()));
311 }
312 let joined = shell_join(cmd);
313 let mut command = tokio::process::Command::new(&self.runner);
314 command
315 .arg("exec")
316 .arg(&handle.id)
317 .args(["sh", "-c", &joined]);
318 let out = command
319 .output()
320 .await
321 .map_err(|e| SandboxError::Exec(e.to_string()))?;
322 Ok(ExecOutput {
323 exit_code: out.status.code().unwrap_or(-1),
324 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
325 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
326 })
327 }
328
329 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
330 let mut command = tokio::process::Command::new(&self.runner);
331 command.arg("rm").arg("-f").arg(&handle.id);
332 let out = command
333 .output()
334 .await
335 .map_err(|e| SandboxError::Exec(e.to_string()))?;
336 if !out.status.success() {
337 tracing::warn!(
338 provider = self.provider.as_str(),
339 stderr = %String::from_utf8_lossy(&out.stderr),
340 "sandbox destroy reported a non-zero status"
341 );
342 }
343 Ok(())
344 }
345}
346
347const DOCKER_TIMEOUT_SECS: u64 = 120;
349
350fn candidate_sockets() -> Vec<String> {
360 let mut out: Vec<String> = Vec::new();
361 let mut push = |p: String| {
362 let p = p.trim().to_string();
363 if !p.is_empty() && !out.contains(&p) {
364 out.push(p);
365 }
366 };
367
368 if let Ok(p) = env::var("ARIA_DOCKER_SOCKET") {
369 push(p);
370 }
371 if let Ok(h) = env::var("DOCKER_HOST") {
372 if let Some(rest) = h.strip_prefix("unix://") {
373 push(rest.to_string());
374 }
375 }
376
377 #[cfg(unix)]
378 {
379 push("/var/run/docker.sock".into());
380 if let Ok(home) = env::var("HOME") {
381 push(format!("{home}/.docker/run/docker.sock"));
383 push(format!("{home}/.colima/default/docker.sock"));
384 }
385 if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
386 push(format!("{xdg}/.docker/run/docker.sock"));
388 push(format!("{xdg}/podman/podman.sock"));
389 }
390 push("/run/podman/podman.sock".into());
391 }
392
393 out
394}
395
396fn connect_first(sockets: &[String]) -> Result<Docker, SandboxError> {
401 let mut last: Option<String> = None;
402 for path in sockets {
403 match Docker::connect_with_socket(path, DOCKER_TIMEOUT_SECS, API_DEFAULT_VERSION) {
404 Ok(docker) => {
405 tracing::debug!(socket = %path, "connected to the Docker daemon");
406 return Ok(docker);
407 }
408 Err(e) => {
409 tracing::debug!(socket = %path, error = %e, "docker socket unavailable");
410 last = Some(e.to_string());
411 }
412 }
413 }
414 Err(SandboxError::NotConfigured(format!(
415 "no reachable Docker daemon: set DOCKER_HOST (or ARIA_DOCKER_SOCKET) to your daemon socket; probed: [{}]{}",
416 sockets.join(", "),
417 last.map(|e| format!(" (last error: {e})")).unwrap_or_default(),
418 )))
419}
420
421pub struct DockerSandbox {
437 docker: OnceCell<Docker>,
438 sockets: Option<Vec<String>>,
440 resources: SandboxResourceLimits,
442 runtime: Option<String>,
444}
445
446impl DockerSandbox {
447 pub fn new() -> Self {
448 Self {
449 docker: OnceCell::new(),
450 sockets: None,
451 resources: SandboxResourceLimits::from_env(),
452 runtime: None,
453 }
454 }
455
456 pub fn runtime(&self) -> Option<&str> {
459 self.runtime.as_deref()
460 }
461
462 pub fn with_socket(path: impl Into<String>) -> Self {
464 Self {
465 docker: OnceCell::new(),
466 sockets: Some(vec![path.into()]),
467 resources: SandboxResourceLimits::from_env(),
468 runtime: None,
469 }
470 }
471
472 pub fn with_runtime(runtime: impl Into<String>) -> Self {
476 Self {
477 docker: OnceCell::new(),
478 sockets: None,
479 resources: SandboxResourceLimits::from_env(),
480 runtime: Some(runtime.into()),
481 }
482 }
483
484 pub async fn client(&self) -> Result<&Docker, SandboxError> {
486 self.docker
487 .get_or_try_init(|| async {
488 match &self.sockets {
489 Some(sockets) => connect_first(sockets),
490 None => match Docker::connect_with_local_defaults() {
493 Ok(docker) => Ok(docker),
494 Err(_) => connect_first(&candidate_sockets()),
495 },
496 }
497 })
498 .await
499 }
500}
501
502#[async_trait]
503impl Sandbox for DockerSandbox {
504 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
505 let docker = self.client().await?;
506 let image = spec
507 .image
508 .clone()
509 .unwrap_or_else(|| "alpine:latest".to_string());
510 let name = format!("aria-sandbox-{}", uuid::Uuid::new_v4());
511 docker
512 .create_container(
513 Some(CreateContainerOptions {
514 name: &name,
515 platform: None,
516 }),
517 Config {
518 image: Some(image),
519 cmd: Some(vec!["sleep".to_string(), "3600".to_string()]),
520 tty: Some(false),
521 env: Some(env_to_docker(&spec.env)),
522 working_dir: spec.workdir.clone(),
523 host_config: Some(build_host_config(&self.resources, self.runtime.as_deref())),
524 ..Default::default()
525 },
526 )
527 .await
528 .map_err(|e| SandboxError::Spawn(e.to_string()))?;
529 docker
530 .start_container(&name, None::<StartContainerOptions<String>>)
531 .await
532 .map_err(|e| SandboxError::Spawn(e.to_string()))?;
533 Ok(SandboxHandle { id: name })
534 }
535
536 async fn exec(
537 &self,
538 handle: &SandboxHandle,
539 cmd: &[String],
540 ) -> Result<ExecOutput, SandboxError> {
541 if cmd.is_empty() {
542 return Err(SandboxError::Exec("empty command".into()));
543 }
544 let joined = shell_join(cmd);
545 let docker = self.client().await?;
546 let exec = docker
547 .create_exec(
548 &handle.id,
549 CreateExecOptions {
550 cmd: Some(vec!["sh".to_string(), "-c".to_string(), joined]),
551 attach_stdout: Some(true),
552 attach_stderr: Some(true),
553 ..Default::default()
554 },
555 )
556 .await
557 .map_err(|e| SandboxError::Exec(e.to_string()))?;
558
559 let id = exec.id.clone();
560 match docker
561 .start_exec(&id, None)
562 .await
563 .map_err(|e| SandboxError::Exec(e.to_string()))?
564 {
565 StartExecResults::Attached { mut output, .. } => {
566 let mut stdout = String::new();
567 let mut stderr = String::new();
568 use futures::StreamExt;
569 while let Some(frame) = output.next().await {
570 match frame.map_err(|e| SandboxError::Exec(e.to_string()))? {
571 bollard::container::LogOutput::StdOut { message } => {
572 stdout.push_str(&String::from_utf8_lossy(&message));
573 }
574 bollard::container::LogOutput::StdErr { message } => {
575 stderr.push_str(&String::from_utf8_lossy(&message));
576 }
577 _ => {}
578 }
579 }
580 let exit_code = docker
581 .inspect_exec(&id)
582 .await
583 .map(|r| r.exit_code.unwrap_or(-1) as i32)
584 .unwrap_or(-1);
585 Ok(ExecOutput {
586 exit_code,
587 stdout,
588 stderr,
589 })
590 }
591 StartExecResults::Detached => Err(SandboxError::Exec(
592 "docker exec returned a detached stream".into(),
593 )),
594 }
595 }
596
597 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
598 let docker = self.client().await?;
599 docker
600 .remove_container(
601 &handle.id,
602 Some(RemoveContainerOptions {
603 force: true,
604 ..Default::default()
605 }),
606 )
607 .await
608 .map_err(|e| SandboxError::Exec(e.to_string()))?;
609 Ok(())
610 }
611}
612
613fn env_to_docker(env: &[(String, String)]) -> Vec<String> {
615 env.iter().map(|(k, v)| format!("{k}={v}")).collect()
616}
617
618pub struct KataSandbox {
625 inner: DockerSandbox,
626}
627
628impl KataSandbox {
629 pub fn new() -> Self {
630 Self {
631 inner: DockerSandbox::with_runtime("kata"),
632 }
633 }
634
635 pub fn runtime(&self) -> Option<&str> {
637 self.inner.runtime()
638 }
639}
640
641#[async_trait]
642impl Sandbox for KataSandbox {
643 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
644 self.inner.spawn(spec).await
645 }
646 async fn exec(
647 &self,
648 handle: &SandboxHandle,
649 cmd: &[String],
650 ) -> Result<ExecOutput, SandboxError> {
651 self.inner.exec(handle, cmd).await
652 }
653 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
654 self.inner.destroy(handle).await
655 }
656}
657
658pub struct CubeSandbox {
661 inner: CliSandbox,
662}
663
664impl CubeSandbox {
665 pub fn new() -> Self {
666 let res = SandboxResourceLimits::from_env();
667 let run_args = vec![
672 "sandbox".into(),
673 "run".into(),
674 "--rm".into(),
675 "--cpus".into(),
676 res.cpus.to_string(),
677 "--memory".into(),
678 res.memory_bytes.to_string(),
679 ];
680 Self {
681 inner: CliSandbox::new(SandboxProvider::Cube, "cube", run_args, "cube-image:latest"),
682 }
683 }
684
685 #[cfg(test)]
689 pub(crate) fn run_args(&self) -> &[String] {
690 self.inner.run_args()
691 }
692}
693
694#[async_trait]
695impl Sandbox for CubeSandbox {
696 async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
697 self.inner.spawn(spec).await
698 }
699 async fn exec(
700 &self,
701 handle: &SandboxHandle,
702 cmd: &[String],
703 ) -> Result<ExecOutput, SandboxError> {
704 self.inner.exec(handle, cmd).await
705 }
706 async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
707 self.inner.destroy(handle).await
708 }
709}
710
711impl Default for DockerSandbox {
712 fn default() -> Self {
713 Self::new()
714 }
715}
716
717impl Default for KataSandbox {
718 fn default() -> Self {
719 Self::new()
720 }
721}
722
723impl Default for CubeSandbox {
724 fn default() -> Self {
725 Self::new()
726 }
727}
728
729pub fn default_sandbox() -> Box<dyn Sandbox> {
731 Box::new(DockerSandbox::new())
732}
733
734pub fn from_provider(provider: SandboxProvider) -> Result<Box<dyn Sandbox>, SandboxError> {
742 match provider {
743 SandboxProvider::Docker => Ok(Box::new(DockerSandbox::new())),
744 SandboxProvider::Kata => Ok(Box::new(KataSandbox::new())),
745 SandboxProvider::Cube => Ok(Box::new(CubeSandbox::new())),
746 SandboxProvider::Codex => Err(SandboxError::NotConfigured(
747 "codex sandbox backend is provided by the aria-agent-cloud runtime; \
748 construct it there and inject via Agent::with_sandbox"
749 .into(),
750 )),
751 }
752}
753
754fn shell_join(cmd: &[String]) -> String {
755 cmd.iter()
756 .map(|a| {
757 if a.contains(char::is_whitespace) || a.contains('"') || a.contains('\'') {
758 format!("'{}'", a.replace('\'', "'\\''"))
759 } else {
760 a.clone()
761 }
762 })
763 .collect::<Vec<_>>()
764 .join(" ")
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn default_is_docker() {
773 let s = default_sandbox();
774 let _ = s;
776 }
777
778 #[test]
779 fn docker_sandbox_construction_never_panics() {
780 let _ = DockerSandbox::new();
783 let _ = DockerSandbox::with_socket("/definitely/not/a/docker.sock");
784 let _ = default_sandbox();
785 }
786
787 #[cfg(unix)]
788 #[test]
789 fn candidate_sockets_include_desktop_and_colima() {
790 let cands = candidate_sockets();
791 assert!(cands.contains(&"/var/run/docker.sock".to_string()));
792 if let Ok(home) = env::var("HOME") {
793 assert!(cands.contains(&format!("{home}/.docker/run/docker.sock")));
795 assert!(cands.contains(&format!("{home}/.colima/default/docker.sock")));
796 }
797 let mut seen = std::collections::HashSet::new();
799 for c in &cands {
800 assert!(seen.insert(c.clone()), "duplicate candidate: {c}");
801 }
802 }
803
804 #[tokio::test]
805 async fn resolve_docker_returns_error_instead_of_panicking() {
806 match DockerSandbox::new().client().await {
809 Ok(_) => {}
810 Err(SandboxError::NotConfigured(msg)) => assert!(msg.contains("DOCKER_HOST")),
811 Err(other) => panic!("unexpected error: {other}"),
812 }
813 }
814
815 #[tokio::test]
816 async fn unreachable_daemon_surfaces_error_not_panic() {
817 let sandbox = DockerSandbox::with_socket("/definitely/not/a/docker.sock");
818 let spec = ExecSpec::command(vec!["true".into()]);
819 let res = sandbox.spawn(&spec).await;
820 assert!(res.is_err(), "spawn must fail when no daemon is reachable");
821 let err = sandbox
822 .exec(&SandboxHandle { id: "nope".into() }, &["true".into()])
823 .await;
824 assert!(err.is_err());
825 let destroyed = sandbox.destroy(SandboxHandle { id: "nope".into() }).await;
826 assert!(destroyed.is_err());
827 }
828
829 #[test]
830 fn provider_parsing() {
831 assert_eq!(
832 SandboxProvider::parse("docker"),
833 Some(SandboxProvider::Docker)
834 );
835 assert_eq!(SandboxProvider::parse("KATA"), Some(SandboxProvider::Kata));
836 assert_eq!(SandboxProvider::parse("cube"), Some(SandboxProvider::Cube));
837 assert_eq!(SandboxProvider::parse("podman"), None);
838 let _ = uuid::Uuid::new_v4();
839 }
840
841 #[test]
842 fn shell_join_quotes() {
843 assert_eq!(
844 shell_join(&["echo".into(), "hello world".into()]),
845 "echo 'hello world'"
846 );
847 }
848
849 #[test]
850 fn exec_spec_command_defaults() {
851 let s = ExecSpec::command(vec!["echo".into(), "hi".into()]);
852 assert_eq!(s.command, vec!["echo", "hi"]);
853 assert_eq!(s.timeout_ms, 60_000);
854 assert!(s.image.is_none());
855 assert!(s.workdir.is_none());
856 assert!(s.env.is_empty());
857 }
858
859 #[test]
860 fn shell_join_empty_and_escapes() {
861 assert_eq!(shell_join(&[]), "");
862 assert_eq!(shell_join(&["a\"b".into()]), "'a\"b'");
864 assert_eq!(shell_join(&["it's".into()]), "'it'\\''s'");
866 }
867
868 #[test]
869 fn provider_as_str_roundtrip() {
870 for p in [
871 SandboxProvider::Docker,
872 SandboxProvider::Kata,
873 SandboxProvider::Cube,
874 SandboxProvider::Codex,
875 ] {
876 let s = p.as_str();
877 assert_eq!(SandboxProvider::parse(s), Some(p));
878 }
879 }
880
881 #[test]
882 fn provider_parse_is_case_insensitive_and_rejects_unknown() {
883 assert_eq!(
884 SandboxProvider::parse("DOCKER"),
885 Some(SandboxProvider::Docker)
886 );
887 assert_eq!(SandboxProvider::parse("Kata"), Some(SandboxProvider::Kata));
888 assert_eq!(
889 SandboxProvider::parse("codex"),
890 Some(SandboxProvider::Codex)
891 );
892 assert_eq!(SandboxProvider::parse("podman"), None);
893 assert_eq!(SandboxProvider::parse(""), None);
894 }
895
896 #[test]
897 fn from_provider_builds_all_published_variants() {
898 for p in [
899 SandboxProvider::Docker,
900 SandboxProvider::Kata,
901 SandboxProvider::Cube,
902 ] {
903 let _ = from_provider(p).expect("published provider must build");
904 }
905 assert!(matches!(
908 from_provider(SandboxProvider::Codex),
909 Err(SandboxError::NotConfigured(_))
910 ));
911 let _ = CubeSandbox::new();
912 let _ = KataSandbox::new();
913 }
914
915 #[test]
916 fn resource_limit_parsers_default_and_reject_bad_input() {
917 assert_eq!(parse_cpu(Some("1.5".into()), 1.0), 1.5);
919 assert_eq!(parse_cpu(Some("0".into()), 1.0), 1.0);
920 assert_eq!(parse_cpu(Some("nope".into()), 2.0), 2.0);
921 assert_eq!(parse_cpu(None, 1.0), 1.0);
922 assert_eq!(parse_memory(Some("512m".into()), 0), 512 * 1024 * 1024);
924 assert_eq!(parse_memory(Some("1g".into()), 0), 1024 * 1024 * 1024);
925 assert_eq!(parse_memory(Some("1024".into()), 0), 1024);
926 assert_eq!(parse_memory(Some("bad".into()), 999), 999);
927 assert_eq!(parse_memory(None, 123), 123);
928 assert_eq!(parse_pids(Some("100".into()), 256), 100);
930 assert_eq!(parse_pids(Some("-1".into()), 256), 256);
931 assert_eq!(parse_pids(None, 256), 256);
932 }
933
934 #[test]
935 fn kata_sandbox_wraps_docker_with_kata_runtime() {
936 let _ = KataSandbox::new();
938 }
939
940 #[test]
941 fn kata_runtime_is_kata() {
942 let kata = KataSandbox::new();
944 assert_eq!(kata.runtime(), Some("kata"));
945 let _ = kata;
946 }
947
948 #[test]
949 fn cube_and_kata_construction_never_panics() {
950 let _ = KataSandbox::new();
951 let _ = CubeSandbox::new();
952 }
953
954 #[test]
955 fn cube_sandbox_maps_resource_limits_to_cli_flags() {
956 let cube = CubeSandbox::new();
960 let res = SandboxResourceLimits::from_env();
961 let expected = [
962 "sandbox".to_string(),
963 "run".to_string(),
964 "--rm".to_string(),
965 "--cpus".to_string(),
966 res.cpus.to_string(),
967 "--memory".to_string(),
968 res.memory_bytes.to_string(),
969 ];
970 assert_eq!(cube.run_args(), &expected[..]);
971 }
972
973 #[tokio::test]
974 async fn kata_spawn_surfaces_error_without_daemon() {
975 let kata = KataSandbox::new();
978 assert_eq!(kata.runtime(), Some("kata"));
979 let spec = ExecSpec::command(vec!["true".into()]);
980 let res = kata.spawn(&spec).await;
981 assert!(
982 res.is_err(),
983 "kata spawn must fail when no daemon is reachable"
984 );
985 }
986
987 #[tokio::test]
988 async fn cube_missing_binary_surfaces_error_not_panic() {
989 let sandbox = CliSandbox::new(
993 SandboxProvider::Cube,
994 "definitely-no-such-binary-xyz",
995 vec!["sandbox".into(), "run".into(), "--rm".into()],
996 "cube-image:latest",
997 );
998 let spec = ExecSpec::command(vec!["true".into()]);
999 let res = sandbox.spawn(&spec).await;
1000 assert!(
1001 res.is_err(),
1002 "spawn must error when the cube binary is missing"
1003 );
1004 let err = sandbox
1005 .exec(&SandboxHandle { id: "nope".into() }, &["true".into()])
1006 .await;
1007 assert!(
1008 err.is_err(),
1009 "exec must error when the cube binary is missing"
1010 );
1011 let _ = sandbox.destroy(SandboxHandle { id: "nope".into() }).await;
1013 }
1014
1015 #[cfg(unix)]
1016 #[tokio::test]
1017 async fn cube_invalid_invocation_surfaces_error_not_panic() {
1018 let sandbox = CliSandbox::new(
1024 SandboxProvider::Cube,
1025 "false",
1026 vec!["sandbox".into(), "run".into(), "--rm".into()],
1027 "cube-image:latest",
1028 );
1029 let spec = ExecSpec::command(vec!["true".into()]);
1030 let res = sandbox.spawn(&spec).await;
1031 assert!(
1032 res.is_err(),
1033 "spawn must error when the cube invocation fails"
1034 );
1035 let out = sandbox
1039 .exec(&SandboxHandle { id: "nope".into() }, &["true".into()])
1040 .await;
1041 assert!(
1042 out.is_ok(),
1043 "exec must not panic when the cube binary is present"
1044 );
1045 assert_eq!(out.unwrap().exit_code, 1);
1046 let _ = sandbox.destroy(SandboxHandle { id: "nope".into() }).await;
1048 }
1049}