skill_runtime/
docker_runtime.rs

1//! Docker Runtime - Execute skills in Docker containers
2//!
3//! This module provides the ability to run skills inside Docker containers,
4//! enabling use of existing container images as skill backends.
5//!
6//! # Example Manifest Configuration
7//!
8//! ```toml
9//! [skills.ffmpeg]
10//! source = "docker:jrottenberg/ffmpeg:5-alpine"
11//! runtime = "docker"
12//!
13//! [skills.ffmpeg.docker]
14//! image = "jrottenberg/ffmpeg:5-alpine"
15//! entrypoint = "/usr/local/bin/ffmpeg"
16//! volumes = ["${SKILL_WORKDIR}:/workdir"]
17//! working_dir = "/workdir"
18//! memory = "512m"
19//! cpus = "2"
20//! network = "none"
21//! rm = true
22//! ```
23
24use anyhow::{anyhow, Context, Result};
25use std::process::Command;
26use tracing::{debug, info, warn};
27
28use crate::manifest::DockerRuntimeConfig;
29
30/// Security constraints for Docker execution
31pub struct DockerSecurityPolicy {
32    /// Block privileged mode
33    pub block_privileged: bool,
34    /// Block docker.sock mounts
35    pub block_docker_sock: bool,
36    /// Block host network
37    pub block_host_network: bool,
38    /// Block mounting sensitive paths
39    pub blocked_mount_paths: Vec<String>,
40    /// Require resource limits
41    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
61/// Docker runtime executor
62pub struct DockerRuntime {
63    policy: DockerSecurityPolicy,
64}
65
66impl DockerRuntime {
67    /// Create a new Docker runtime with default security policy
68    pub fn new() -> Self {
69        Self {
70            policy: DockerSecurityPolicy::default(),
71        }
72    }
73
74    /// Create with custom security policy
75    pub fn with_policy(policy: DockerSecurityPolicy) -> Self {
76        Self { policy }
77    }
78
79    /// Check if Docker is available
80    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    /// Validate Docker configuration against security policy
89    pub fn validate_config(&self, config: &DockerRuntimeConfig) -> Result<()> {
90        // Check for privileged flag in extra_args
91        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        // Check for docker.sock mounts
98        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        // Check for host network
107        if self.policy.block_host_network && config.network == "host" {
108            return Err(anyhow!("Security policy blocks host network mode"));
109        }
110
111        // Check for blocked mount paths
112        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        // Check resource limits if required
125        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    /// Build docker run command arguments
138    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        // Remove container after execution
148        if config.rm {
149            args.push("--rm".to_string());
150        }
151
152        // Network mode (default: none for isolation)
153        args.push("--network".to_string());
154        args.push(config.network.clone());
155
156        // Memory limit
157        if let Some(ref memory) = config.memory {
158            args.push("--memory".to_string());
159            args.push(memory.clone());
160        }
161
162        // CPU limit
163        if let Some(ref cpus) = config.cpus {
164            args.push("--cpus".to_string());
165            args.push(cpus.clone());
166        }
167
168        // Working directory
169        if let Some(ref workdir) = config.working_dir {
170            args.push("--workdir".to_string());
171            args.push(workdir.clone());
172        }
173
174        // User
175        if let Some(ref user) = config.user {
176            args.push("--user".to_string());
177            args.push(user.clone());
178        }
179
180        // GPU support
181        if let Some(ref gpus) = config.gpus {
182            args.push("--gpus".to_string());
183            args.push(gpus.clone());
184        }
185
186        // Read-only filesystem
187        if config.read_only {
188            args.push("--read-only".to_string());
189        }
190
191        // Platform (multi-arch)
192        if let Some(ref platform) = config.platform {
193            args.push("--platform".to_string());
194            args.push(platform.clone());
195        }
196
197        // Volume mounts
198        for volume in &config.volumes {
199            args.push("-v".to_string());
200            args.push(volume.clone());
201        }
202
203        // Environment variables
204        for env_var in &config.environment {
205            args.push("-e".to_string());
206            args.push(env_var.clone());
207        }
208
209        // Extra args (validated against policy)
210        for extra in &config.extra_args {
211            args.push(extra.clone());
212        }
213
214        // Entrypoint override
215        if let Some(ref entrypoint) = config.entrypoint {
216            args.push("--entrypoint".to_string());
217            args.push(entrypoint.clone());
218        }
219
220        // Image
221        args.push(config.image.clone());
222
223        // Command/args
224        if let Some(ref cmd) = config.command {
225            args.extend(cmd.iter().cloned());
226        }
227
228        // Additional tool arguments
229        args.extend(tool_args.iter().cloned());
230
231        Ok(args)
232    }
233
234    /// Execute a Docker container and capture output
235    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    /// Pull an image if not already present
273    pub fn ensure_image(&self, image: &str) -> Result<()> {
274        info!("Ensuring Docker image: {}", image);
275
276        // Check if image exists locally
277        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        // Pull the image
288        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/// Output from Docker container execution
310#[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, // Allow privileged for testing
459            ..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        // Should pass with relaxed policy
469        assert!(runtime.validate_config(&config).is_ok());
470    }
471}