skill_runtime/
docker_runtime.rs1use anyhow::{anyhow, Context, Result};
25use std::process::Command;
26use tracing::{debug, info, warn};
27
28use crate::manifest::DockerRuntimeConfig;
29
30pub struct DockerSecurityPolicy {
32 pub block_privileged: bool,
34 pub block_docker_sock: bool,
36 pub block_host_network: bool,
38 pub blocked_mount_paths: Vec<String>,
40 pub require_resource_limits: bool,
42}
43
44impl Default for DockerSecurityPolicy {
45 fn default() -> Self {
46 Self {
47 block_privileged: true,
48 block_docker_sock: true,
49 block_host_network: true,
50 blocked_mount_paths: vec![
51 "/etc/passwd".to_string(),
52 "/etc/shadow".to_string(),
53 "/var/run/docker.sock".to_string(),
54 "/root".to_string(),
55 ],
56 require_resource_limits: false,
57 }
58 }
59}
60
61pub struct DockerRuntime {
63 policy: DockerSecurityPolicy,
64}
65
66impl DockerRuntime {
67 pub fn new() -> Self {
69 Self {
70 policy: DockerSecurityPolicy::default(),
71 }
72 }
73
74 pub fn with_policy(policy: DockerSecurityPolicy) -> Self {
76 Self { policy }
77 }
78
79 pub fn is_available() -> bool {
81 Command::new("docker")
82 .arg("version")
83 .output()
84 .map(|o| o.status.success())
85 .unwrap_or(false)
86 }
87
88 pub fn validate_config(&self, config: &DockerRuntimeConfig) -> Result<()> {
90 if self.policy.block_privileged {
92 if config.extra_args.iter().any(|a| a.contains("--privileged")) {
93 return Err(anyhow!("Security policy blocks --privileged mode"));
94 }
95 }
96
97 if self.policy.block_docker_sock {
99 for volume in &config.volumes {
100 if volume.contains("docker.sock") {
101 return Err(anyhow!("Security policy blocks mounting docker.sock"));
102 }
103 }
104 }
105
106 if self.policy.block_host_network && config.network == "host" {
108 return Err(anyhow!("Security policy blocks host network mode"));
109 }
110
111 for volume in &config.volumes {
113 let host_path = volume.split(':').next().unwrap_or("");
114 for blocked in &self.policy.blocked_mount_paths {
115 if host_path.starts_with(blocked) {
116 return Err(anyhow!(
117 "Security policy blocks mounting path: {}",
118 blocked
119 ));
120 }
121 }
122 }
123
124 if self.policy.require_resource_limits {
126 if config.memory.is_none() {
127 warn!("No memory limit set for Docker skill");
128 }
129 if config.cpus.is_none() {
130 warn!("No CPU limit set for Docker skill");
131 }
132 }
133
134 Ok(())
135 }
136
137 pub fn build_command(
139 &self,
140 config: &DockerRuntimeConfig,
141 tool_args: &[String],
142 ) -> Result<Vec<String>> {
143 self.validate_config(config)?;
144
145 let mut args = vec!["run".to_string()];
146
147 if config.rm {
149 args.push("--rm".to_string());
150 }
151
152 args.push("--network".to_string());
154 args.push(config.network.clone());
155
156 if let Some(ref memory) = config.memory {
158 args.push("--memory".to_string());
159 args.push(memory.clone());
160 }
161
162 if let Some(ref cpus) = config.cpus {
164 args.push("--cpus".to_string());
165 args.push(cpus.clone());
166 }
167
168 if let Some(ref workdir) = config.working_dir {
170 args.push("--workdir".to_string());
171 args.push(workdir.clone());
172 }
173
174 if let Some(ref user) = config.user {
176 args.push("--user".to_string());
177 args.push(user.clone());
178 }
179
180 if let Some(ref gpus) = config.gpus {
182 args.push("--gpus".to_string());
183 args.push(gpus.clone());
184 }
185
186 if config.read_only {
188 args.push("--read-only".to_string());
189 }
190
191 if let Some(ref platform) = config.platform {
193 args.push("--platform".to_string());
194 args.push(platform.clone());
195 }
196
197 for volume in &config.volumes {
199 args.push("-v".to_string());
200 args.push(volume.clone());
201 }
202
203 for env_var in &config.environment {
205 args.push("-e".to_string());
206 args.push(env_var.clone());
207 }
208
209 for extra in &config.extra_args {
211 args.push(extra.clone());
212 }
213
214 if let Some(ref entrypoint) = config.entrypoint {
216 args.push("--entrypoint".to_string());
217 args.push(entrypoint.clone());
218 }
219
220 args.push(config.image.clone());
222
223 if let Some(ref cmd) = config.command {
225 args.extend(cmd.iter().cloned());
226 }
227
228 args.extend(tool_args.iter().cloned());
230
231 Ok(args)
232 }
233
234 pub fn execute(
236 &self,
237 config: &DockerRuntimeConfig,
238 tool_args: &[String],
239 ) -> Result<DockerOutput> {
240 let args = self.build_command(config, tool_args)?;
241
242 debug!("Docker command: docker {}", args.join(" "));
243
244 let output = Command::new("docker")
245 .args(&args)
246 .output()
247 .context("Failed to execute docker command")?;
248
249 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
250 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
251
252 if output.status.success() {
253 info!("Docker container executed successfully");
254 Ok(DockerOutput {
255 success: true,
256 stdout,
257 stderr,
258 exit_code: output.status.code().unwrap_or(0),
259 })
260 } else {
261 let exit_code = output.status.code().unwrap_or(-1);
262 warn!("Docker container failed with exit code {}", exit_code);
263 Ok(DockerOutput {
264 success: false,
265 stdout,
266 stderr,
267 exit_code,
268 })
269 }
270 }
271
272 pub fn ensure_image(&self, image: &str) -> Result<()> {
274 info!("Ensuring Docker image: {}", image);
275
276 let check = Command::new("docker")
278 .args(["image", "inspect", image])
279 .output()
280 .context("Failed to check for docker image")?;
281
282 if check.status.success() {
283 debug!("Image {} already exists locally", image);
284 return Ok(());
285 }
286
287 info!("Pulling Docker image: {}", image);
289 let pull = Command::new("docker")
290 .args(["pull", image])
291 .output()
292 .context("Failed to pull docker image")?;
293
294 if !pull.status.success() {
295 let stderr = String::from_utf8_lossy(&pull.stderr);
296 return Err(anyhow!("Failed to pull image {}: {}", image, stderr));
297 }
298
299 Ok(())
300 }
301}
302
303impl Default for DockerRuntime {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309#[derive(Debug, Clone)]
311pub struct DockerOutput {
312 pub success: bool,
313 pub stdout: String,
314 pub stderr: String,
315 pub exit_code: i32,
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_docker_runtime_creation() {
324 let runtime = DockerRuntime::new();
325 assert!(runtime.policy.block_privileged);
326 assert!(runtime.policy.block_docker_sock);
327 }
328
329 #[test]
330 fn test_build_basic_command() {
331 let runtime = DockerRuntime::new();
332 let config = DockerRuntimeConfig {
333 image: "alpine:latest".to_string(),
334 ..Default::default()
335 };
336
337 let args = runtime.build_command(&config, &[]).unwrap();
338 assert!(args.contains(&"run".to_string()));
339 assert!(args.contains(&"--rm".to_string()));
340 assert!(args.contains(&"--network".to_string()));
341 assert!(args.contains(&"none".to_string()));
342 assert!(args.contains(&"alpine:latest".to_string()));
343 }
344
345 #[test]
346 fn test_build_command_with_volumes() {
347 let runtime = DockerRuntime::new();
348 let config = DockerRuntimeConfig {
349 image: "python:3.11".to_string(),
350 volumes: vec!["/tmp/data:/data".to_string()],
351 working_dir: Some("/data".to_string()),
352 ..Default::default()
353 };
354
355 let args = runtime.build_command(&config, &[]).unwrap();
356 assert!(args.contains(&"-v".to_string()));
357 assert!(args.contains(&"/tmp/data:/data".to_string()));
358 assert!(args.contains(&"--workdir".to_string()));
359 assert!(args.contains(&"/data".to_string()));
360 }
361
362 #[test]
363 fn test_build_command_with_resources() {
364 let runtime = DockerRuntime::new();
365 let config = DockerRuntimeConfig {
366 image: "nvidia/cuda:12.0".to_string(),
367 memory: Some("4g".to_string()),
368 cpus: Some("2".to_string()),
369 gpus: Some("all".to_string()),
370 ..Default::default()
371 };
372
373 let args = runtime.build_command(&config, &[]).unwrap();
374 assert!(args.contains(&"--memory".to_string()));
375 assert!(args.contains(&"4g".to_string()));
376 assert!(args.contains(&"--cpus".to_string()));
377 assert!(args.contains(&"2".to_string()));
378 assert!(args.contains(&"--gpus".to_string()));
379 assert!(args.contains(&"all".to_string()));
380 }
381
382 #[test]
383 fn test_security_blocks_privileged() {
384 let runtime = DockerRuntime::new();
385 let config = DockerRuntimeConfig {
386 image: "alpine".to_string(),
387 extra_args: vec!["--privileged".to_string()],
388 ..Default::default()
389 };
390
391 let result = runtime.validate_config(&config);
392 assert!(result.is_err());
393 assert!(result.unwrap_err().to_string().contains("privileged"));
394 }
395
396 #[test]
397 fn test_security_blocks_docker_sock() {
398 let runtime = DockerRuntime::new();
399 let config = DockerRuntimeConfig {
400 image: "alpine".to_string(),
401 volumes: vec!["/var/run/docker.sock:/var/run/docker.sock".to_string()],
402 ..Default::default()
403 };
404
405 let result = runtime.validate_config(&config);
406 assert!(result.is_err());
407 assert!(result.unwrap_err().to_string().contains("docker.sock"));
408 }
409
410 #[test]
411 fn test_security_blocks_host_network() {
412 let runtime = DockerRuntime::new();
413 let config = DockerRuntimeConfig {
414 image: "alpine".to_string(),
415 network: "host".to_string(),
416 ..Default::default()
417 };
418
419 let result = runtime.validate_config(&config);
420 assert!(result.is_err());
421 assert!(result.unwrap_err().to_string().contains("host network"));
422 }
423
424 #[test]
425 fn test_build_command_with_entrypoint() {
426 let runtime = DockerRuntime::new();
427 let config = DockerRuntimeConfig {
428 image: "jrottenberg/ffmpeg:5".to_string(),
429 entrypoint: Some("/usr/local/bin/ffmpeg".to_string()),
430 ..Default::default()
431 };
432
433 let args = runtime.build_command(&config, &["-version".to_string()]).unwrap();
434 assert!(args.contains(&"--entrypoint".to_string()));
435 assert!(args.contains(&"/usr/local/bin/ffmpeg".to_string()));
436 assert!(args.contains(&"-version".to_string()));
437 }
438
439 #[test]
440 fn test_build_command_with_environment() {
441 let runtime = DockerRuntime::new();
442 let config = DockerRuntimeConfig {
443 image: "node:20".to_string(),
444 environment: vec!["NODE_ENV=production".to_string(), "PORT=3000".to_string()],
445 ..Default::default()
446 };
447
448 let args = runtime.build_command(&config, &[]).unwrap();
449 let e_count = args.iter().filter(|a| *a == "-e").count();
450 assert_eq!(e_count, 2);
451 assert!(args.contains(&"NODE_ENV=production".to_string()));
452 assert!(args.contains(&"PORT=3000".to_string()));
453 }
454
455 #[test]
456 fn test_custom_security_policy() {
457 let policy = DockerSecurityPolicy {
458 block_privileged: false, ..Default::default()
460 };
461 let runtime = DockerRuntime::with_policy(policy);
462 let config = DockerRuntimeConfig {
463 image: "alpine".to_string(),
464 extra_args: vec!["--privileged".to_string()],
465 ..Default::default()
466 };
467
468 assert!(runtime.validate_config(&config).is_ok());
470 }
471}