construct/runtime/
docker.rs1use super::traits::RuntimeAdapter;
2use crate::config::DockerRuntimeConfig;
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone)]
8pub struct DockerRuntime {
9 config: DockerRuntimeConfig,
10}
11
12impl DockerRuntime {
13 pub fn new(config: DockerRuntimeConfig) -> Self {
14 Self { config }
15 }
16
17 fn workspace_mount_path(&self, workspace_dir: &Path) -> Result<PathBuf> {
18 let resolved = workspace_dir
19 .canonicalize()
20 .unwrap_or_else(|_| workspace_dir.to_path_buf());
21
22 if !resolved.is_absolute() {
23 anyhow::bail!(
24 "Docker runtime requires an absolute workspace path, got: {}",
25 resolved.display()
26 );
27 }
28
29 if resolved == Path::new("/") {
30 anyhow::bail!("Refusing to mount filesystem root (/) into docker runtime");
31 }
32
33 if self.config.allowed_workspace_roots.is_empty() {
34 return Ok(resolved);
35 }
36
37 let allowed = self.config.allowed_workspace_roots.iter().any(|root| {
38 let root_path = Path::new(root)
39 .canonicalize()
40 .unwrap_or_else(|_| PathBuf::from(root));
41 resolved.starts_with(root_path)
42 });
43
44 if !allowed {
45 anyhow::bail!(
46 "Workspace path {} is not in runtime.docker.allowed_workspace_roots",
47 resolved.display()
48 );
49 }
50
51 Ok(resolved)
52 }
53}
54
55impl RuntimeAdapter for DockerRuntime {
56 fn name(&self) -> &str {
57 "docker"
58 }
59
60 fn has_shell_access(&self) -> bool {
61 true
62 }
63
64 fn has_filesystem_access(&self) -> bool {
65 self.config.mount_workspace
66 }
67
68 fn storage_path(&self) -> PathBuf {
69 if self.config.mount_workspace {
70 PathBuf::from("/workspace/.construct")
71 } else {
72 PathBuf::from("/tmp/.construct")
73 }
74 }
75
76 fn supports_long_running(&self) -> bool {
77 false
78 }
79
80 fn memory_budget(&self) -> u64 {
81 self.config
82 .memory_limit_mb
83 .map_or(0, |mb| mb.saturating_mul(1024 * 1024))
84 }
85
86 fn build_shell_command(
87 &self,
88 command: &str,
89 workspace_dir: &Path,
90 ) -> anyhow::Result<tokio::process::Command> {
91 let mut process = tokio::process::Command::new("docker");
92 process
93 .arg("run")
94 .arg("--rm")
95 .arg("--init")
96 .arg("--interactive");
97
98 let network = self.config.network.trim();
99 if !network.is_empty() {
100 process.arg("--network").arg(network);
101 }
102
103 if let Some(memory_limit_mb) = self.config.memory_limit_mb.filter(|mb| *mb > 0) {
104 process.arg("--memory").arg(format!("{memory_limit_mb}m"));
105 }
106
107 if let Some(cpu_limit) = self.config.cpu_limit.filter(|cpus| *cpus > 0.0) {
108 process.arg("--cpus").arg(cpu_limit.to_string());
109 }
110
111 if self.config.read_only_rootfs {
112 process.arg("--read-only");
113 }
114
115 if self.config.mount_workspace {
116 let host_workspace = self.workspace_mount_path(workspace_dir).with_context(|| {
117 format!(
118 "Failed to validate workspace mount path {}",
119 workspace_dir.display()
120 )
121 })?;
122
123 process
124 .arg("--volume")
125 .arg(format!("{}:/workspace:rw", host_workspace.display()))
126 .arg("--workdir")
127 .arg("/workspace");
128 }
129
130 process
131 .arg(self.config.image.trim())
132 .arg("sh")
133 .arg("-c")
134 .arg(command);
135
136 Ok(process)
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn docker_runtime_name() {
146 let runtime = DockerRuntime::new(DockerRuntimeConfig::default());
147 assert_eq!(runtime.name(), "docker");
148 }
149
150 #[test]
151 fn docker_runtime_memory_budget() {
152 let mut cfg = DockerRuntimeConfig::default();
153 cfg.memory_limit_mb = Some(256);
154 let runtime = DockerRuntime::new(cfg);
155 assert_eq!(runtime.memory_budget(), 256 * 1024 * 1024);
156 }
157
158 #[test]
159 fn docker_build_shell_command_includes_runtime_flags() {
160 let cfg = DockerRuntimeConfig {
161 image: "alpine:3.20".into(),
162 network: "none".into(),
163 memory_limit_mb: Some(128),
164 cpu_limit: Some(1.5),
165 read_only_rootfs: true,
166 mount_workspace: true,
167 allowed_workspace_roots: Vec::new(),
168 };
169 let runtime = DockerRuntime::new(cfg);
170
171 let workspace = std::env::temp_dir();
172 let command = runtime
173 .build_shell_command("echo hello", &workspace)
174 .unwrap();
175 let debug = format!("{command:?}");
176
177 assert!(debug.contains("docker"));
178 assert!(debug.contains("--memory"));
179 assert!(debug.contains("128m"));
180 assert!(debug.contains("--cpus"));
181 assert!(debug.contains("1.5"));
182 assert!(debug.contains("--workdir"));
183 assert!(debug.contains("echo hello"));
184 }
185
186 #[test]
187 fn docker_workspace_allowlist_blocks_outside_paths() {
188 let cfg = DockerRuntimeConfig {
189 allowed_workspace_roots: vec!["/tmp/allowed".into()],
190 ..DockerRuntimeConfig::default()
191 };
192 let runtime = DockerRuntime::new(cfg);
193
194 let outside = PathBuf::from("/tmp/blocked_workspace");
195 let result = runtime.build_shell_command("echo test", &outside);
196
197 assert!(result.is_err());
198 }
199
200 #[test]
203 fn docker_build_shell_command_includes_network_flag() {
204 let cfg = DockerRuntimeConfig {
205 network: "none".into(),
206 ..DockerRuntimeConfig::default()
207 };
208 let runtime = DockerRuntime::new(cfg);
209 let workspace = std::env::temp_dir();
210 let cmd = runtime
211 .build_shell_command("echo hello", &workspace)
212 .unwrap();
213 let debug = format!("{cmd:?}");
214 assert!(
215 debug.contains("--network") && debug.contains("none"),
216 "must include --network none for isolation"
217 );
218 }
219
220 #[test]
221 fn docker_build_shell_command_includes_read_only_flag() {
222 let cfg = DockerRuntimeConfig {
223 read_only_rootfs: true,
224 ..DockerRuntimeConfig::default()
225 };
226 let runtime = DockerRuntime::new(cfg);
227 let workspace = std::env::temp_dir();
228 let cmd = runtime
229 .build_shell_command("echo hello", &workspace)
230 .unwrap();
231 let debug = format!("{cmd:?}");
232 assert!(
233 debug.contains("--read-only"),
234 "must include --read-only flag when read_only_rootfs is set"
235 );
236 }
237
238 #[cfg(unix)]
239 #[test]
240 fn docker_refuses_root_mount() {
241 let cfg = DockerRuntimeConfig {
242 mount_workspace: true,
243 ..DockerRuntimeConfig::default()
244 };
245 let runtime = DockerRuntime::new(cfg);
246 let result = runtime.build_shell_command("echo test", Path::new("/"));
247 assert!(
248 result.is_err(),
249 "mounting filesystem root (/) must be refused"
250 );
251 let error_chain = format!("{:#}", result.unwrap_err());
252 assert!(
253 error_chain.contains("root"),
254 "expected root-mount error chain, got: {error_chain}"
255 );
256 }
257
258 #[test]
259 fn docker_no_memory_flag_when_not_configured() {
260 let cfg = DockerRuntimeConfig {
261 memory_limit_mb: None,
262 ..DockerRuntimeConfig::default()
263 };
264 let runtime = DockerRuntime::new(cfg);
265 let workspace = std::env::temp_dir();
266 let cmd = runtime
267 .build_shell_command("echo hello", &workspace)
268 .unwrap();
269 let debug = format!("{cmd:?}");
270 assert!(
271 !debug.contains("--memory"),
272 "should not include --memory when not configured"
273 );
274 }
275}