1#![allow(dead_code)]
3
4use anyhow::{bail, Context, Result};
5use tokio::process::Command;
6
7use super::config::SandboxConfig;
8
9pub struct DockerSandbox {
11 container_id: Option<String>,
12 config: SandboxConfig,
13 workspace_path: String,
15}
16
17#[derive(Debug)]
19pub struct SandboxOutput {
20 pub stdout: String,
21 pub stderr: String,
22 pub exit_code: i32,
23}
24
25impl DockerSandbox {
26 pub fn new(config: SandboxConfig, workspace_path: impl Into<String>) -> Self {
27 Self {
28 container_id: None,
29 config,
30 workspace_path: workspace_path.into(),
31 }
32 }
33
34 pub async fn is_docker_available() -> bool {
36 Command::new("docker")
37 .args(["info", "--format", "{{.ServerVersion}}"])
38 .output()
39 .await
40 .map(|o| o.status.success())
41 .unwrap_or(false)
42 }
43
44 pub async fn is_sandbox_available() -> bool {
48 let output = Command::new("docker")
49 .args(["version", "--format", "{{.Client.Version}}"])
50 .output()
51 .await;
52
53 match output {
54 Ok(o) if o.status.success() => {
55 let version = String::from_utf8_lossy(&o.stdout);
56 let version = version.trim();
57 !version.is_empty()
60 }
61 _ => false,
62 }
63 }
64
65 async fn auto_detect_gh_token() {
71 if std::env::var("GH_TOKEN").is_ok() || std::env::var("GITHUB_TOKEN").is_ok() {
73 return;
74 }
75
76 let output = Command::new("gh")
77 .args(["auth", "token"])
78 .output()
79 .await;
80
81 if let Ok(o) = output {
82 if o.status.success() {
83 let token = String::from_utf8_lossy(&o.stdout).trim().to_string();
84 if !token.is_empty() {
85 std::env::set_var("GH_TOKEN", &token);
86 tracing::info!("Auto-detected GH_TOKEN from `gh auth token`");
87 }
88 }
89 }
90 }
91
92 pub async fn create(&mut self) -> Result<()> {
94 if !Self::is_sandbox_available().await {
95 bail!(
96 "Docker Sandbox is not available. \
97 Please install Docker Desktop 4.40+ (https://www.docker.com/products/docker-desktop/). \
98 Ensure the Docker daemon is running before retrying."
99 );
100 }
101
102 let image = self.config.image().to_string();
103 let workspace = &self.workspace_path;
104
105 let mut args = vec![
106 "create".to_string(),
107 "--rm".to_string(),
108 "-v".to_string(),
109 format!("{workspace}:/workspace"),
110 "-w".to_string(),
111 "/workspace".to_string(),
112 ];
113
114 Self::auto_detect_gh_token().await;
120
121 for key in self.config.effective_env() {
125 if let Ok(val) = std::env::var(&key) {
126 args.extend(["-e".to_string(), format!("{key}={val}")]);
127 }
128 }
129 args.extend(["-e".to_string(), "HOME=/root".to_string()]);
131
132 for vol in self.config.effective_volumes() {
136 args.extend(["-v".to_string(), vol]);
137 }
138
139 if let Some(cpus) = self.config.resources.cpus {
141 args.extend(["--cpus".to_string(), cpus.to_string()]);
142 }
143 if let Some(ref mem) = self.config.resources.memory {
144 args.extend(["--memory".to_string(), mem.clone()]);
145 }
146
147 for dns in &self.config.dns {
149 args.extend(["--dns".to_string(), dns.clone()]);
150 }
151
152 if !self.config.network.deny.is_empty() || !self.config.network.allow.is_empty() {
154 args.extend(["--network".to_string(), "bridge".to_string()]);
155 }
156
157 args.push(image);
158 args.push("sleep".to_string());
159 args.push("infinity".to_string());
160
161 let output = Command::new("docker")
162 .args(&args)
163 .output()
164 .await
165 .context("Failed to run docker create")?;
166
167 if !output.status.success() {
168 let stderr = String::from_utf8_lossy(&output.stderr);
169 bail!("docker create failed: {stderr}");
170 }
171
172 let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
173 self.container_id = Some(id.clone());
174
175 let start_output = Command::new("docker")
177 .args(["start", &id])
178 .output()
179 .await
180 .context("Failed to start container")?;
181
182 if !start_output.status.success() {
183 let stderr = String::from_utf8_lossy(&start_output.stderr);
184 bail!("docker start failed: {stderr}");
185 }
186
187 tracing::info!(container_id = %id, "Sandbox container started");
188 Ok(())
189 }
190
191 pub async fn copy_workspace(&self, src: &str) -> Result<()> {
202 let id = self.container_id.as_ref().context("Container not created")?;
203
204 let effective_exclude = self.config.effective_exclude();
205 if effective_exclude.is_empty() {
206 let output = Command::new("docker")
208 .args(["cp", &format!("{src}/."), &format!("{id}:/workspace")])
209 .output()
210 .await
211 .context("docker cp failed")?;
212
213 if !output.status.success() {
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 bail!("docker cp workspace failed: {stderr}");
216 }
217 } else {
218 let mut excludes = String::new();
225 for pattern in &effective_exclude {
226 excludes.push_str(&format!(" --exclude='{pattern}'"));
227 }
228
229 let pipe_cmd = format!(
230 "tar -cf - --no-xattrs --no-mac-metadata{excludes} -C '{src}' . 2>/dev/null \
231 | docker exec -i {id} tar -xf - -C /workspace 2>/dev/null; \
232 exit 0"
233 );
234
235 let output = Command::new("/bin/sh")
236 .args(["-c", &pipe_cmd])
237 .output()
238 .await
239 .context("tar | docker exec pipe failed")?;
240
241 let verify = Command::new("docker")
246 .args(["exec", id, "test", "-d", "/workspace/.git"])
247 .output()
248 .await
249 .context("workspace verification failed")?;
250
251 if !verify.status.success() {
252 let stderr = String::from_utf8_lossy(&output.stderr);
253 bail!("docker cp workspace failed — .git directory not found in container: {stderr}");
254 }
255 }
256
257 Ok(())
258 }
259
260 pub async fn run_command(&self, cmd: &str) -> Result<SandboxOutput> {
262 let id = self.container_id.as_ref().context("Container not created")?;
263
264 tracing::debug!(container_id = %id, cmd = %cmd, "Sandbox: executing command");
265
266 let output = Command::new("docker")
267 .args(["exec", id, "/bin/sh", "-c", cmd])
268 .output()
269 .await
270 .context("docker exec failed")?;
271
272 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
273 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
274 let exit_code = output.status.code().unwrap_or(-1);
275
276 tracing::debug!(
277 exit_code,
278 stdout_len = stdout.len(),
279 stderr_len = stderr.len(),
280 stdout_preview = %if stdout.len() > 200 { &stdout[..200] } else { &stdout },
281 stderr_preview = %if stderr.len() > 200 { &stderr[..200] } else { &stderr },
282 "Sandbox: command completed"
283 );
284
285 Ok(SandboxOutput { stdout, stderr, exit_code })
286 }
287
288 pub async fn copy_results(&self, dest: &str) -> Result<()> {
295 let id = self.container_id.as_ref().context("Container not created")?;
296
297 let check = Command::new("docker")
300 .args(["exec", id, "git", "-C", "/workspace", "status", "--porcelain"])
301 .output()
302 .await;
303
304 if let Ok(output) = check {
305 let changed = String::from_utf8_lossy(&output.stdout);
306 let changed = changed.trim();
307 if changed.is_empty() {
308 tracing::info!("No files changed in sandbox — skipping copy-back");
309 return Ok(());
310 }
311 tracing::info!(changed_files = %changed, "Sandbox has modified files — copying back");
312 }
313
314 let pipe_cmd = format!(
317 "docker exec {id} sh -c \
318 'cd /workspace && git diff --name-only HEAD 2>/dev/null; \
319 git ls-files --others --exclude-standard 2>/dev/null' \
320 | while read f; do \
321 docker cp \"{id}:/workspace/$f\" \"{dest}/$f\" 2>/dev/null; \
322 done; exit 0"
323 );
324
325 Command::new("/bin/sh")
326 .args(["-c", &pipe_cmd])
327 .output()
328 .await
329 .context("copy results from container failed")?;
330
331 Ok(())
332 }
333
334 pub async fn destroy(&mut self) -> Result<()> {
336 if let Some(id) = self.container_id.take() {
337 let output = Command::new("docker")
338 .args(["rm", "-f", &id])
339 .output()
340 .await
341 .context("docker rm failed")?;
342
343 if !output.status.success() {
344 let stderr = String::from_utf8_lossy(&output.stderr);
345 tracing::warn!("docker rm warning: {stderr}");
346 } else {
347 tracing::info!(container_id = %id, "Sandbox container destroyed");
348 }
349 }
350 Ok(())
351 }
352}
353
354impl Drop for DockerSandbox {
356 fn drop(&mut self) {
357 if let Some(id) = &self.container_id {
358 let _ = std::process::Command::new("docker")
360 .args(["rm", "-f", id])
361 .output();
362 }
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::sandbox::config::{NetworkConfig, ResourceConfig, SandboxConfig};
370
371 fn make_config() -> SandboxConfig {
372 SandboxConfig {
373 enabled: true,
374 image: Some("ubuntu:22.04".to_string()),
375 workspace: Some("/tmp/test".to_string()),
376 network: NetworkConfig::default(),
377 resources: ResourceConfig {
378 cpus: Some(1.0),
379 memory: Some("512m".to_string()),
380 },
381 env: vec![],
382 volumes: vec![],
383 exclude: vec![],
384 dns: vec![],
385 }
386 }
387
388 #[test]
389 fn sandbox_new_has_no_container() {
390 let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
391 assert!(sb.container_id.is_none());
392 }
393
394 #[test]
395 fn sandbox_destroy_when_no_container_is_noop() {
396 let mut sb = DockerSandbox::new(make_config(), "/tmp/workspace");
398 sb.container_id = None;
399 drop(sb); }
401
402 #[tokio::test]
405 async fn run_command_returns_stdout() {
406 let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
409 let result = sb.run_command("echo hello").await;
411 assert!(result.is_err());
412 assert!(result.unwrap_err().to_string().contains("Container not created"));
413
414 let r2 = sb.copy_results("/tmp/dest").await;
416 assert!(r2.is_err());
417
418 let r3 = sb.copy_workspace("/tmp/src").await;
419 assert!(r3.is_err());
420 }
421
422 #[test]
423 fn config_image_fallback() {
424 let cfg = SandboxConfig::default();
425 let sb = DockerSandbox::new(cfg, "/tmp");
426 assert_eq!(sb.config.image(), "minion-sandbox:latest");
427 }
428}