1use crate::error::AgentError;
2use bollard::container::{Config, CreateContainerOptions, RemoveContainerOptions};
3use bollard::Docker;
4use futures::StreamExt;
5use tokio::time::{timeout, Duration};
6use tracing::instrument;
7pub struct DockerSandbox {
8 docker: Docker,
9}
10
11impl DockerSandbox {
12 pub async fn check_docker_available() -> bool {
14 if let Ok(docker) = Docker::connect_with_defaults() {
15 if docker.ping().await.is_ok() {
16 return true;
17 }
18 }
19 false
20 }
21
22 pub async fn new() -> Result<Self, AgentError> {
24 let docker = Docker::connect_with_defaults()
25 .map_err(|e| AgentError::SandboxError(format!("Failed to connect to Docker: {}", e)))?;
26
27 docker
29 .ping()
30 .await
31 .map_err(|e| AgentError::SandboxError(format!("Docker ping failed: {}", e)))?;
32
33 Ok(DockerSandbox { docker })
34 }
35
36 #[instrument(skip(self, image))]
38 pub async fn create_container(&self, image: &str) -> Result<String, AgentError> {
39 let image_name = if image.is_empty() {
41 "limit-rust-sandbox:latest"
42 } else {
43 image
44 };
45
46 let cwd = std::env::current_dir().map_err(|e| {
47 AgentError::SandboxError(format!("Failed to get current directory: {}", e))
48 })?;
49
50 let config = Config {
51 image: Some(image_name.to_string()),
52 tty: Some(false),
53 open_stdin: Some(false),
54 host_config: Some(bollard::models::HostConfig {
55 binds: Some(vec![format!("{}:/workspace:ro", cwd.to_string_lossy())]),
56 memory: Some(512 * 1024 * 1024), network_mode: Some("none".to_string()),
58 ..Default::default()
59 }),
60 working_dir: Some("/workspace".to_string()),
61 ..Default::default()
62 };
63
64 let uuid = uuid::Uuid::new_v4().to_string();
65 let short_uuid = uuid.chars().take(8).collect::<String>();
66 let options = Some(CreateContainerOptions {
67 name: format!("limit-sandbox-{}", short_uuid),
68 ..Default::default()
69 });
70
71 let container = self
72 .docker
73 .create_container(options, config)
74 .await
75 .map_err(|e| AgentError::SandboxError(format!("Failed to create container: {}", e)))?;
76
77 Ok(container.id)
78 }
79
80 #[instrument(skip(self, container, cmd))]
82 pub async fn execute_in_container(
83 &self,
84 container: &str,
85 cmd: &[String],
86 ) -> Result<String, AgentError> {
87 let exec_config = bollard::exec::CreateExecOptions {
89 cmd: Some(cmd.to_vec()),
90 attach_stdout: Some(true),
91 attach_stderr: Some(true),
92 ..Default::default()
93 };
94
95 let exec = self
96 .docker
97 .create_exec(container, exec_config)
98 .await
99 .map_err(|e| AgentError::SandboxError(format!("Failed to create exec: {}", e)))?;
100
101 let exec_start = bollard::exec::StartExecOptions {
103 detach: false,
104 ..Default::default()
105 };
106
107 let result = timeout(
108 Duration::from_secs(60),
109 self.docker.start_exec(&exec.id, Some(exec_start)),
110 )
111 .await
112 .map_err(|_| AgentError::SandboxError("Command execution timed out".to_string()))?
113 .map_err(|e| AgentError::SandboxError(format!("Failed to start exec: {}", e)))?;
114
115 let output = match result {
116 bollard::exec::StartExecResults::Attached { output, .. } => {
117 let mut full_output = Vec::new();
118
119 let mut stream = output;
120 while let Some(result) = stream.next().await {
121 let chunk = result.map_err(|e| {
122 AgentError::SandboxError(format!("Failed to read output: {}", e))
123 })?;
124 full_output.extend_from_slice(&chunk.into_bytes());
125 }
126
127 String::from_utf8_lossy(&full_output).to_string()
128 }
129 bollard::exec::StartExecResults::Detached => String::new(),
130 };
131
132 Ok(output)
133 }
134
135 #[instrument(skip(self, container))]
137 pub async fn start_container(&self, container: &str) -> Result<(), AgentError> {
138 self.docker
139 .start_container::<String>(container, None)
140 .await
141 .map_err(|e| AgentError::SandboxError(format!("Failed to start container: {}", e)))?;
142
143 Ok(())
144 }
145
146 pub async fn cleanup_container(&self, container: &str) {
148 let remove_options = RemoveContainerOptions {
149 force: true,
150 v: true,
151 ..Default::default()
152 };
153
154 let _ = self
155 .docker
156 .remove_container(container, Some(remove_options))
157 .await;
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[tokio::test]
166 async fn test_check_docker_available() {
167 let available = DockerSandbox::check_docker_available().await;
168 println!("Docker available: {}", available);
170 }
171
172 #[tokio::test]
173 async fn test_docker_not_available_returns_error() {
174 if !DockerSandbox::check_docker_available().await {
176 let result = DockerSandbox::new().await;
177 assert!(result.is_err());
178 }
179 }
180
181 #[tokio::test]
182 #[ignore] async fn test_create_and_cleanup_container() {
184 if !DockerSandbox::check_docker_available().await {
185 return;
186 }
187
188 let sandbox = DockerSandbox::new().await.unwrap();
189 let container_id = sandbox.create_container("alpine:latest").await.unwrap();
190
191 sandbox.cleanup_container(&container_id).await;
193 }
194
195 #[tokio::test]
196 #[ignore] async fn test_execute_command() {
198 if !DockerSandbox::check_docker_available().await {
199 return;
200 }
201
202 let sandbox = DockerSandbox::new().await.unwrap();
203 let container_id = sandbox.create_container("alpine:latest").await.unwrap();
204
205 sandbox.start_container(&container_id).await.unwrap();
206
207 let output = sandbox
208 .execute_in_container(&container_id, &["echo".to_string(), "hello".to_string()])
209 .await
210 .unwrap();
211
212 assert!(output.contains("hello"));
213
214 sandbox.cleanup_container(&container_id).await;
215 }
216}