1use crate::docker_types::{
4 ContainerInspect, DockerInfo, ImageInspect, NetworkInspect, VolumeInspect,
5};
6use crate::error::{MigrationError, Result};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use tempfile::TempDir;
11use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
12use tokio::process::{Child, Command};
13
14use crate::helper_image::helper_image_reference;
15
16const HELPER_IMAGE_REFERENCE: &str = helper_image_reference();
17
18#[derive(Clone)]
20pub struct DockerCliRunner {
21 binary: PathBuf,
22 socket_path: PathBuf,
23 isolated_config: Arc<TempDir>,
24}
25
26impl std::fmt::Debug for DockerCliRunner {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.debug_struct("DockerCliRunner")
29 .field("binary", &self.binary)
30 .field("socket_path", &self.socket_path)
31 .finish_non_exhaustive()
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct CreateNetworkOptions {
38 pub internal: bool,
40 pub enable_ipv6: bool,
42 pub attachable: bool,
44 pub labels: Vec<(String, String)>,
46 pub options: Vec<(String, String)>,
48 pub ipam: Vec<(String, String, String)>,
50}
51
52impl DockerCliRunner {
53 pub fn new(socket_path: impl Into<PathBuf>) -> Result<Self> {
59 Ok(Self {
60 binary: resolve_docker_binary().ok_or_else(|| {
61 MigrationError::Docker("failed to locate `docker` in PATH".into())
62 })?,
63 socket_path: socket_path.into(),
64 isolated_config: Arc::new(tempfile::tempdir()?),
65 })
66 }
67
68 #[must_use]
70 pub fn socket_path(&self) -> &Path {
71 &self.socket_path
72 }
73
74 #[must_use]
76 pub const fn helper_image_reference(&self) -> &'static str {
77 HELPER_IMAGE_REFERENCE
78 }
79
80 pub async fn info(&self) -> Result<DockerInfo> {
82 self.json_object(&["info", "--format", "{{json .}}"]).await
83 }
84
85 pub async fn list_images(&self) -> Result<Vec<ImageInspect>> {
87 let ids = self.lines(&["image", "ls", "-aq", "--no-trunc"]).await?;
88 self.inspect_many::<ImageInspect>("image", &ids).await
89 }
90
91 pub async fn list_volumes(&self) -> Result<Vec<VolumeInspect>> {
93 let names = self.lines(&["volume", "ls", "-q"]).await?;
94 self.inspect_many::<VolumeInspect>("volume", &names).await
95 }
96
97 pub async fn list_networks(&self) -> Result<Vec<NetworkInspect>> {
99 let ids = self
100 .lines(&["network", "ls", "--filter", "type=custom", "-q"])
101 .await?;
102 self.inspect_many::<NetworkInspect>("network", &ids).await
103 }
104
105 pub async fn list_containers(&self) -> Result<Vec<ContainerInspect>> {
107 let ids = self
108 .lines(&["container", "ls", "-aq", "--no-trunc"])
109 .await?;
110 self.inspect_many::<ContainerInspect>("container", &ids)
111 .await
112 }
113
114 pub async fn stop_container(&self, id: &str) -> Result<()> {
116 self.status(["container", "stop", "--time", "30", id]).await
117 }
118
119 pub async fn remove_container(&self, id: &str) -> Result<()> {
121 self.status(["container", "rm", "--force", "--volumes", id])
122 .await
123 }
124
125 pub async fn remove_volume(&self, name: &str) -> Result<()> {
127 self.status(["volume", "rm", "--force", name]).await
128 }
129
130 pub async fn remove_network(&self, name: &str) -> Result<()> {
132 self.status(["network", "rm", name]).await
133 }
134
135 pub async fn create_volume(
137 &self,
138 name: &str,
139 labels: &[(String, String)],
140 options: &[(String, String)],
141 ) -> Result<()> {
142 let mut args = vec!["volume".to_string(), "create".to_string(), name.to_string()];
143 for (key, value) in labels {
144 args.push("--label".to_string());
145 args.push(format!("{key}={value}"));
146 }
147 for (key, value) in options {
148 args.push("--opt".to_string());
149 args.push(format!("{key}={value}"));
150 }
151 self.status_owned(args).await
152 }
153
154 pub async fn create_network(&self, name: &str, config: &CreateNetworkOptions) -> Result<()> {
156 let mut args = vec![
157 "network".to_string(),
158 "create".to_string(),
159 "--driver".to_string(),
160 "bridge".to_string(),
161 ];
162 if config.internal {
163 args.push("--internal".to_string());
164 }
165 if config.enable_ipv6 {
166 args.push("--ipv6".to_string());
167 }
168 if config.attachable {
169 args.push("--attachable".to_string());
170 }
171 for (key, value) in &config.labels {
172 args.push("--label".to_string());
173 args.push(format!("{key}={value}"));
174 }
175 for (key, value) in &config.options {
176 args.push("--opt".to_string());
177 args.push(format!("{key}={value}"));
178 }
179 for (subnet, gateway, ip_range) in &config.ipam {
180 if !subnet.is_empty() {
181 args.push("--subnet".to_string());
182 args.push(subnet.clone());
183 }
184 if !gateway.is_empty() {
185 args.push("--gateway".to_string());
186 args.push(gateway.clone());
187 }
188 if !ip_range.is_empty() {
189 args.push("--ip-range".to_string());
190 args.push(ip_range.clone());
191 }
192 }
193 args.push(name.to_string());
194 self.status_owned(args).await
195 }
196
197 pub async fn create_container<I, S>(&self, args: I) -> Result<String>
199 where
200 I: IntoIterator<Item = S>,
201 S: AsRef<str>,
202 {
203 let output = self.output(["container", "create"], args).await?;
204 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
205 }
206
207 pub async fn connect_network(
209 &self,
210 network: &str,
211 container: &str,
212 aliases: &[String],
213 ) -> Result<()> {
214 let mut args = vec!["network".to_string(), "connect".to_string()];
215 for alias in aliases {
216 args.push("--alias".to_string());
217 args.push(alias.clone());
218 }
219 args.push(network.to_string());
220 args.push(container.to_string());
221 self.status_owned(args).await
222 }
223
224 pub async fn create_helper_container(&self, name: &str, volume_name: &str) -> Result<String> {
226 self.create_container([
227 "--name",
228 name,
229 "--mount",
230 &format!("type=volume,src={volume_name},dst=/volume"),
231 HELPER_IMAGE_REFERENCE,
232 "/helper",
233 ])
234 .await
235 }
236
237 pub async fn ensure_helper_image(&self) -> Result<()> {
239 let status = Command::new(&self.binary)
240 .arg("--host")
241 .arg(self.host_arg())
242 .args(["image", "inspect", HELPER_IMAGE_REFERENCE])
243 .env("DOCKER_CONFIG", self.isolated_config.path())
244 .env("DOCKER_CLI_HINTS", "false")
245 .stdout(Stdio::null())
246 .stderr(Stdio::null())
247 .status()
248 .await?;
249 if status.success() {
250 return Ok(());
251 }
252
253 let mut child = self
254 .command()
255 .args(["image", "import", "-", HELPER_IMAGE_REFERENCE])
256 .stdin(Stdio::piped())
257 .stdout(Stdio::null())
258 .stderr(Stdio::piped())
259 .spawn()?;
260 if let Some(mut stdin) = child.stdin.take() {
261 stdin.write_all(&empty_tar_bytes()).await?;
262 }
263 wait_for_success(child, "docker image import").await
264 }
265
266 pub async fn save_image(&self, reference: &str) -> Result<tempfile::NamedTempFile> {
268 let file = tempfile::NamedTempFile::new()?;
269 let path = file.path().to_path_buf();
270 self.status_owned(vec![
271 "image".to_string(),
272 "save".to_string(),
273 "--output".to_string(),
274 path.to_string_lossy().to_string(),
275 reference.to_string(),
276 ])
277 .await?;
278 Ok(file)
279 }
280
281 pub async fn load_image(&self, path: &Path) -> Result<()> {
283 self.status_owned(vec![
284 "image".to_string(),
285 "load".to_string(),
286 "--quiet".to_string(),
287 "--input".to_string(),
288 path.to_string_lossy().to_string(),
289 ])
290 .await
291 }
292
293 pub async fn copy_from_container(
295 &self,
296 container: &str,
297 source_path: &str,
298 ) -> Result<tempfile::NamedTempFile> {
299 let file = tempfile::NamedTempFile::new()?;
300 let path = file.path().to_path_buf();
301 let mut child = self
302 .command()
303 .args([
304 "container",
305 "cp",
306 &format!("{container}:{source_path}"),
307 "-",
308 ])
309 .stdout(Stdio::piped())
310 .stderr(Stdio::piped())
311 .spawn()?;
312
313 let mut stdout = child
314 .stdout
315 .take()
316 .ok_or_else(|| MigrationError::Docker("docker cp stdout missing".into()))?;
317 let mut dest = tokio::fs::File::create(&path).await?;
318 let stdout_task =
319 tokio::spawn(async move { tokio::io::copy(&mut stdout, &mut dest).await.map(|_| ()) });
320 let stderr_task = tokio::spawn(take_stderr(child.stderr.take()));
322 let status = child.wait().await?;
323 stdout_task
324 .await
325 .map_err(|e| MigrationError::Docker(format!("docker cp copy task failed: {e}")))??;
326 let stderr = stderr_task
327 .await
328 .map_err(|e| MigrationError::Docker(format!("docker cp stderr task failed: {e}")))??;
329 if !status.success() {
330 return Err(MigrationError::Docker(format!(
331 "docker container cp failed: {}",
332 stderr.trim()
333 )));
334 }
335 Ok(file)
336 }
337
338 pub async fn copy_to_container(
340 &self,
341 source_archive: &Path,
342 container: &str,
343 target_path: &str,
344 ) -> Result<()> {
345 let mut child = self
346 .command()
347 .args([
348 "container",
349 "cp",
350 "-",
351 &format!("{container}:{target_path}"),
352 ])
353 .stdin(Stdio::piped())
354 .stdout(Stdio::null())
355 .stderr(Stdio::piped())
356 .spawn()?;
357 let mut stdin = child
358 .stdin
359 .take()
360 .ok_or_else(|| MigrationError::Docker("docker cp stdin missing".into()))?;
361 let mut source = tokio::fs::File::open(source_archive).await?;
362 let write_task = tokio::spawn(async move {
363 tokio::io::copy(&mut source, &mut stdin).await?;
364 stdin.shutdown().await
365 });
366 let stderr_task = tokio::spawn(take_stderr(child.stderr.take()));
368 let status = child.wait().await?;
369 write_task
370 .await
371 .map_err(|e| MigrationError::Docker(format!("docker cp write task failed: {e}")))??;
372 let stderr = stderr_task
373 .await
374 .map_err(|e| MigrationError::Docker(format!("docker cp stderr task failed: {e}")))??;
375 if !status.success() {
376 return Err(MigrationError::Docker(format!(
377 "docker container cp failed: {}",
378 stderr.trim()
379 )));
380 }
381 Ok(())
382 }
383
384 async fn inspect_many<T>(&self, noun: &str, ids: &[String]) -> Result<Vec<T>>
385 where
386 T: serde::de::DeserializeOwned,
387 {
388 if ids.is_empty() {
389 return Ok(Vec::new());
390 }
391 let mut args = vec![noun.to_string(), "inspect".to_string()];
392 args.extend(ids.iter().cloned());
393 self.json_array_owned(args).await
394 }
395
396 async fn json_object<T>(&self, args: &[&str]) -> Result<T>
397 where
398 T: serde::de::DeserializeOwned,
399 {
400 let output = self
401 .output_owned(args.iter().map(ToString::to_string).collect())
402 .await?;
403 serde_json::from_slice(&output.stdout).map_err(Into::into)
404 }
405
406 async fn json_array_owned<T>(&self, args: Vec<String>) -> Result<Vec<T>>
407 where
408 T: serde::de::DeserializeOwned,
409 {
410 let output = self.output_owned(args).await?;
411 serde_json::from_slice(&output.stdout).map_err(Into::into)
412 }
413
414 async fn lines(&self, args: &[&str]) -> Result<Vec<String>> {
415 let output = self
416 .output_owned(args.iter().map(ToString::to_string).collect())
417 .await?;
418 Ok(String::from_utf8_lossy(&output.stdout)
419 .lines()
420 .map(str::trim)
421 .filter(|line| !line.is_empty())
422 .map(ToOwned::to_owned)
423 .collect())
424 }
425
426 async fn status<I, S>(&self, args: I) -> Result<()>
427 where
428 I: IntoIterator<Item = S>,
429 S: AsRef<str>,
430 {
431 self.output_owned(
432 args.into_iter()
433 .map(|arg| arg.as_ref().to_string())
434 .collect(),
435 )
436 .await
437 .map(|_| ())
438 }
439
440 async fn status_owned(&self, args: Vec<String>) -> Result<()> {
441 self.output_owned(args).await.map(|_| ())
442 }
443
444 async fn output<I, S, J, T>(&self, prefix: I, rest: J) -> Result<std::process::Output>
445 where
446 I: IntoIterator<Item = S>,
447 S: AsRef<str>,
448 J: IntoIterator<Item = T>,
449 T: AsRef<str>,
450 {
451 let mut args: Vec<String> = prefix
452 .into_iter()
453 .map(|item| item.as_ref().to_string())
454 .collect();
455 args.extend(rest.into_iter().map(|item| item.as_ref().to_string()));
456 self.output_owned(args).await
457 }
458
459 async fn output_owned(&self, args: Vec<String>) -> Result<std::process::Output> {
460 let output = self
461 .command()
462 .args(args)
463 .output()
464 .await
465 .map_err(|e| MigrationError::Docker(format!("failed to run docker: {e}")))?;
466 if output.status.success() {
467 return Ok(output);
468 }
469 Err(MigrationError::Docker(
470 String::from_utf8_lossy(&output.stderr).trim().to_string(),
471 ))
472 }
473
474 fn command(&self) -> Command {
475 let mut command = Command::new(&self.binary);
476 command
477 .arg("--host")
478 .arg(self.host_arg())
479 .env("DOCKER_CONFIG", self.isolated_config.path())
480 .env("DOCKER_CLI_HINTS", "false")
481 .env("NO_COLOR", "1")
482 .kill_on_drop(true);
483 command
484 }
485
486 fn host_arg(&self) -> String {
487 format!("unix://{}", self.socket_path.display())
488 }
489}
490
491async fn wait_for_success(mut child: Child, context: &str) -> Result<()> {
492 let stderr = take_stderr(child.stderr.take()).await?;
493 let status = child.wait().await?;
494 if status.success() {
495 Ok(())
496 } else {
497 Err(MigrationError::Docker(format!(
498 "{context} failed: {}",
499 stderr.trim()
500 )))
501 }
502}
503
504async fn take_stderr(stderr: Option<tokio::process::ChildStderr>) -> Result<String> {
505 let mut stderr =
506 stderr.ok_or_else(|| MigrationError::Docker("docker stderr pipe missing".into()))?;
507 let mut buf = Vec::new();
508 stderr.read_to_end(&mut buf).await?;
509 Ok(String::from_utf8_lossy(&buf).to_string())
510}
511
512fn resolve_docker_binary() -> Option<PathBuf> {
513 if let Some(path) = find_in_path("docker") {
514 return Some(path);
515 }
516
517 let home = dirs::home_dir()?;
518 let candidates = [
519 home.join(".arcbox/bin/docker"),
520 home.join(".arcbox/runtime/bin/docker"),
521 PathBuf::from("/opt/homebrew/bin/docker"),
522 PathBuf::from("/usr/local/bin/docker"),
523 PathBuf::from("/Applications/Docker.app/Contents/Resources/bin/docker"),
524 ];
525 candidates.into_iter().find(|path: &PathBuf| path.is_file())
526}
527
528fn find_in_path(binary: &str) -> Option<PathBuf> {
529 let path_var = std::env::var_os("PATH")?;
530 for directory in std::env::split_paths(&path_var) {
531 let candidate = directory.join(binary);
532 if candidate.is_file() {
533 return Some(candidate);
534 }
535 }
536 None
537}
538
539fn empty_tar_bytes() -> Vec<u8> {
540 vec![0; 1024]
541}
542
543#[cfg(test)]
544mod tests {
545 use super::{empty_tar_bytes, find_in_path};
546
547 #[test]
548 fn empty_tar_has_two_zero_blocks() {
549 assert_eq!(empty_tar_bytes().len(), 1024);
550 assert!(empty_tar_bytes().iter().all(|byte| *byte == 0));
551 }
552
553 #[test]
554 fn path_lookup_handles_missing_binary() {
555 assert!(find_in_path("definitely-not-a-real-binary").is_none());
556 }
557}