1#![allow(dead_code)]
3
4use anyhow::{bail, Context, Result};
5use tokio::process::Command;
6
7use super::config::SandboxConfig;
8
9pub const EMBEDDED_DOCKERFILE: &str = include_str!("../../Dockerfile.sandbox");
12
13pub struct DockerSandbox {
15 container_id: Option<String>,
16 config: SandboxConfig,
17 workspace_path: String,
19}
20
21#[derive(Debug)]
23pub struct SandboxOutput {
24 pub stdout: String,
25 pub stderr: String,
26 pub exit_code: i32,
27}
28
29impl DockerSandbox {
30 pub fn new(config: SandboxConfig, workspace_path: impl Into<String>) -> Self {
31 Self {
32 container_id: None,
33 config,
34 workspace_path: workspace_path.into(),
35 }
36 }
37
38 pub async fn image_exists(image: &str) -> bool {
40 Command::new("docker")
41 .args(["image", "inspect", image])
42 .stdout(std::process::Stdio::null())
43 .stderr(std::process::Stdio::null())
44 .status()
45 .await
46 .map(|s| s.success())
47 .unwrap_or(false)
48 }
49
50 pub async fn auto_build_image(image: &str) -> Result<()> {
53 let tmp = std::env::temp_dir().join("minion-sandbox-build");
54 std::fs::create_dir_all(&tmp)
55 .context("Failed to create temp dir for Docker build")?;
56
57 let dockerfile_path = tmp.join("Dockerfile");
58 std::fs::write(&dockerfile_path, EMBEDDED_DOCKERFILE)
59 .context("Failed to write embedded Dockerfile")?;
60
61 tracing::info!("Building Docker image '{image}' (this may take a few minutes on first run)...");
62 eprintln!(
63 "\n ⟳ Building Docker image '{}' — first run only, please wait...\n",
64 image,
65 );
66
67 let output = Command::new("docker")
68 .args(["build", "-t", image, "-f"])
69 .arg(&dockerfile_path)
70 .arg(&tmp)
71 .stdout(std::process::Stdio::inherit())
72 .stderr(std::process::Stdio::inherit())
73 .status()
74 .await
75 .context("Failed to run docker build")?;
76
77 let _ = std::fs::remove_dir_all(&tmp);
79
80 if !output.success() {
81 bail!(
82 "Docker build failed for image '{image}'. \
83 Check the output above for errors."
84 );
85 }
86
87 tracing::info!("Successfully built Docker image '{image}'");
88 Ok(())
89 }
90
91 pub async fn is_docker_available() -> bool {
93 Command::new("docker")
94 .args(["info", "--format", "{{.ServerVersion}}"])
95 .output()
96 .await
97 .map(|o| o.status.success())
98 .unwrap_or(false)
99 }
100
101 pub async fn is_sandbox_available() -> bool {
105 let output = Command::new("docker")
106 .args(["version", "--format", "{{.Client.Version}}"])
107 .output()
108 .await;
109
110 match output {
111 Ok(o) if o.status.success() => {
112 let version = String::from_utf8_lossy(&o.stdout);
113 let version = version.trim();
114 !version.is_empty()
117 }
118 _ => false,
119 }
120 }
121
122 async fn auto_detect_gh_token() {
128 if std::env::var("GH_TOKEN").is_ok() || std::env::var("GITHUB_TOKEN").is_ok() {
130 return;
131 }
132
133 let output = Command::new("gh")
134 .args(["auth", "token"])
135 .output()
136 .await;
137
138 if let Ok(o) = output {
139 if o.status.success() {
140 let token = String::from_utf8_lossy(&o.stdout).trim().to_string();
141 if !token.is_empty() {
142 std::env::set_var("GH_TOKEN", &token);
143 tracing::info!("Auto-detected GH_TOKEN from `gh auth token`");
144 }
145 }
146 }
147 }
148
149 pub async fn create(&mut self) -> Result<()> {
151 if !Self::is_sandbox_available().await {
152 bail!(
153 "Docker Sandbox is not available. \
154 Please install Docker Desktop 4.40+ (https://www.docker.com/products/docker-desktop/). \
155 Ensure the Docker daemon is running before retrying."
156 );
157 }
158
159 let image = self.config.image().to_string();
160 let workspace = &self.workspace_path;
161
162 let mut args = vec![
163 "create".to_string(),
164 "--rm".to_string(),
165 "-v".to_string(),
166 format!("{workspace}:/workspace"),
167 "-w".to_string(),
168 "/workspace".to_string(),
169 ];
170
171 Self::auto_detect_gh_token().await;
177
178 for key in self.config.effective_env() {
182 if let Ok(val) = std::env::var(&key) {
183 args.extend(["-e".to_string(), format!("{key}={val}")]);
184 }
185 }
186 args.extend(["-e".to_string(), "HOME=/root".to_string()]);
188
189 for vol in self.config.effective_volumes() {
193 args.extend(["-v".to_string(), vol]);
194 }
195
196 if let Some(cpus) = self.config.resources.cpus {
198 args.extend(["--cpus".to_string(), cpus.to_string()]);
199 }
200 if let Some(ref mem) = self.config.resources.memory {
201 args.extend(["--memory".to_string(), mem.clone()]);
202 }
203
204 for dns in &self.config.dns {
206 args.extend(["--dns".to_string(), dns.clone()]);
207 }
208
209 if !self.config.network.deny.is_empty() || !self.config.network.allow.is_empty() {
211 args.extend(["--network".to_string(), "bridge".to_string()]);
212 }
213
214 args.push(image);
215 args.push("sleep".to_string());
216 args.push("infinity".to_string());
217
218 let output = Command::new("docker")
219 .args(&args)
220 .output()
221 .await
222 .context("Failed to run docker create")?;
223
224 if !output.status.success() {
225 let stderr = String::from_utf8_lossy(&output.stderr);
226 bail!("docker create failed: {stderr}");
227 }
228
229 let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
230 self.container_id = Some(id.clone());
231
232 let start_output = Command::new("docker")
234 .args(["start", &id])
235 .output()
236 .await
237 .context("Failed to start container")?;
238
239 if !start_output.status.success() {
240 let stderr = String::from_utf8_lossy(&start_output.stderr);
241 bail!("docker start failed: {stderr}");
242 }
243
244 tracing::info!(container_id = %id, "Sandbox container started");
245 Ok(())
246 }
247
248 pub async fn copy_workspace(&self, src: &str) -> Result<()> {
259 let id = self.container_id.as_ref().context("Container not created")?;
260
261 let effective_exclude = self.config.effective_exclude();
262 if effective_exclude.is_empty() {
263 let output = Command::new("docker")
265 .args(["cp", &format!("{src}/."), &format!("{id}:/workspace")])
266 .output()
267 .await
268 .context("docker cp failed")?;
269
270 if !output.status.success() {
271 let stderr = String::from_utf8_lossy(&output.stderr);
272 bail!("docker cp workspace failed: {stderr}");
273 }
274 } else {
275 let mut excludes = String::new();
282 for pattern in &effective_exclude {
283 excludes.push_str(&format!(" --exclude='{pattern}'"));
284 }
285
286 let pipe_cmd = format!(
287 "tar -cf - --no-xattrs --no-mac-metadata{excludes} -C '{src}' . 2>/dev/null \
288 | docker exec -i {id} tar -xf - -C /workspace 2>/dev/null; \
289 exit 0"
290 );
291
292 let output = Command::new("/bin/sh")
293 .args(["-c", &pipe_cmd])
294 .output()
295 .await
296 .context("tar | docker exec pipe failed")?;
297
298 let verify = Command::new("docker")
303 .args(["exec", id, "test", "-d", "/workspace/.git"])
304 .output()
305 .await
306 .context("workspace verification failed")?;
307
308 if !verify.status.success() {
309 let stderr = String::from_utf8_lossy(&output.stderr);
310 bail!("docker cp workspace failed — .git directory not found in container: {stderr}");
311 }
312 }
313
314 Ok(())
315 }
316
317 pub async fn run_command(&self, cmd: &str) -> Result<SandboxOutput> {
319 let id = self.container_id.as_ref().context("Container not created")?;
320
321 tracing::debug!(container_id = %id, cmd = %cmd, "Sandbox: executing command");
322
323 let output = Command::new("docker")
324 .args(["exec", id, "/bin/sh", "-c", cmd])
325 .output()
326 .await
327 .context("docker exec failed")?;
328
329 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
330 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
331 let exit_code = output.status.code().unwrap_or(-1);
332
333 tracing::debug!(
334 exit_code,
335 stdout_len = stdout.len(),
336 stderr_len = stderr.len(),
337 stdout_preview = %if stdout.len() > 200 { &stdout[..200] } else { &stdout },
338 stderr_preview = %if stderr.len() > 200 { &stderr[..200] } else { &stderr },
339 "Sandbox: command completed"
340 );
341
342 Ok(SandboxOutput { stdout, stderr, exit_code })
343 }
344
345 pub async fn copy_results(&self, dest: &str) -> Result<()> {
352 let id = self.container_id.as_ref().context("Container not created")?;
353
354 let check = Command::new("docker")
357 .args(["exec", id, "git", "-C", "/workspace", "status", "--porcelain"])
358 .output()
359 .await;
360
361 if let Ok(output) = check {
362 let changed = String::from_utf8_lossy(&output.stdout);
363 let changed = changed.trim();
364 if changed.is_empty() {
365 tracing::info!("No files changed in sandbox — skipping copy-back");
366 return Ok(());
367 }
368 tracing::info!(changed_files = %changed, "Sandbox has modified files — copying back");
369 }
370
371 let pipe_cmd = format!(
374 "docker exec {id} sh -c \
375 'cd /workspace && git diff --name-only HEAD 2>/dev/null; \
376 git ls-files --others --exclude-standard 2>/dev/null' \
377 | while read f; do \
378 docker cp \"{id}:/workspace/$f\" \"{dest}/$f\" 2>/dev/null; \
379 done; exit 0"
380 );
381
382 Command::new("/bin/sh")
383 .args(["-c", &pipe_cmd])
384 .output()
385 .await
386 .context("copy results from container failed")?;
387
388 Ok(())
389 }
390
391 pub async fn destroy(&mut self) -> Result<()> {
393 if let Some(id) = self.container_id.take() {
394 let output = Command::new("docker")
395 .args(["rm", "-f", &id])
396 .output()
397 .await
398 .context("docker rm failed")?;
399
400 if !output.status.success() {
401 let stderr = String::from_utf8_lossy(&output.stderr);
402 tracing::warn!("docker rm warning: {stderr}");
403 } else {
404 tracing::info!(container_id = %id, "Sandbox container destroyed");
405 }
406 }
407 Ok(())
408 }
409}
410
411impl Drop for DockerSandbox {
413 fn drop(&mut self) {
414 if let Some(id) = &self.container_id {
415 let _ = std::process::Command::new("docker")
417 .args(["rm", "-f", id])
418 .output();
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::sandbox::config::{NetworkConfig, ResourceConfig, SandboxConfig};
427
428 fn make_config() -> SandboxConfig {
429 SandboxConfig {
430 enabled: true,
431 image: Some("ubuntu:22.04".to_string()),
432 workspace: Some("/tmp/test".to_string()),
433 network: NetworkConfig::default(),
434 resources: ResourceConfig {
435 cpus: Some(1.0),
436 memory: Some("512m".to_string()),
437 },
438 env: vec![],
439 volumes: vec![],
440 exclude: vec![],
441 dns: vec![],
442 }
443 }
444
445 #[test]
446 fn sandbox_new_has_no_container() {
447 let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
448 assert!(sb.container_id.is_none());
449 }
450
451 #[test]
452 fn sandbox_destroy_when_no_container_is_noop() {
453 let mut sb = DockerSandbox::new(make_config(), "/tmp/workspace");
455 sb.container_id = None;
456 drop(sb); }
458
459 #[tokio::test]
462 async fn run_command_returns_stdout() {
463 let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
466 let result = sb.run_command("echo hello").await;
468 assert!(result.is_err());
469 assert!(result.unwrap_err().to_string().contains("Container not created"));
470
471 let r2 = sb.copy_results("/tmp/dest").await;
473 assert!(r2.is_err());
474
475 let r3 = sb.copy_workspace("/tmp/src").await;
476 assert!(r3.is_err());
477 }
478
479 #[test]
480 fn config_image_fallback() {
481 let cfg = SandboxConfig::default();
482 let sb = DockerSandbox::new(cfg, "/tmp");
483 assert_eq!(sb.config.image(), "minion-sandbox:latest");
484 }
485}