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, PartialEq, Eq)]
37pub struct ImageTransfer {
38 pub bytes: u64,
40 pub loaded_image_id: Option<String>,
46}
47
48fn parse_loaded_image_id(stdout: &str) -> Option<String> {
54 stdout.lines().find_map(|line| {
55 line.trim()
56 .strip_prefix("Loaded image ID:")
57 .map(|id| id.trim().to_string())
58 .filter(|id| !id.is_empty())
59 })
60}
61
62#[derive(Debug, Clone)]
64pub struct CreateNetworkOptions {
65 pub internal: bool,
67 pub enable_ipv6: bool,
69 pub attachable: bool,
71 pub labels: Vec<(String, String)>,
73 pub options: Vec<(String, String)>,
75 pub ipam: Vec<(String, String, String)>,
77}
78
79impl DockerCliRunner {
80 pub fn new(socket_path: impl Into<PathBuf>) -> Result<Self> {
86 Ok(Self {
87 binary: resolve_docker_binary().ok_or_else(|| {
88 MigrationError::Docker("failed to locate `docker` in PATH".into())
89 })?,
90 socket_path: socket_path.into(),
91 isolated_config: Arc::new(tempfile::tempdir()?),
92 })
93 }
94
95 #[must_use]
97 pub fn socket_path(&self) -> &Path {
98 &self.socket_path
99 }
100
101 #[must_use]
103 pub const fn helper_image_reference(&self) -> &'static str {
104 HELPER_IMAGE_REFERENCE
105 }
106
107 pub async fn info(&self) -> Result<DockerInfo> {
109 self.json_object(&["info", "--format", "{{json .}}"]).await
110 }
111
112 pub async fn list_images(&self) -> Result<Vec<ImageInspect>> {
114 let ids = self.lines(&["image", "ls", "-aq", "--no-trunc"]).await?;
115 self.inspect_many::<ImageInspect>("image", &ids).await
116 }
117
118 pub async fn list_volumes(&self) -> Result<Vec<VolumeInspect>> {
120 let names = self.lines(&["volume", "ls", "-q"]).await?;
121 self.inspect_many::<VolumeInspect>("volume", &names).await
122 }
123
124 pub async fn list_networks(&self) -> Result<Vec<NetworkInspect>> {
126 let ids = self
127 .lines(&["network", "ls", "--filter", "type=custom", "-q"])
128 .await?;
129 self.inspect_many::<NetworkInspect>("network", &ids).await
130 }
131
132 pub async fn list_containers(&self) -> Result<Vec<ContainerInspect>> {
134 let ids = self
135 .lines(&["container", "ls", "-aq", "--no-trunc"])
136 .await?;
137 self.inspect_many::<ContainerInspect>("container", &ids)
138 .await
139 }
140
141 pub async fn stop_container(&self, id: &str) -> Result<()> {
143 self.status(["container", "stop", "--time", "30", id]).await
144 }
145
146 pub async fn start_container(&self, id: &str) -> Result<()> {
148 self.status(["container", "start", id]).await
149 }
150
151 pub async fn remove_container(&self, id: &str) -> Result<()> {
153 self.status(["container", "rm", "--force", "--volumes", id])
154 .await
155 }
156
157 pub async fn remove_stale_helper(&self, name: &str) -> Result<()> {
171 if !self.container_exists(name).await {
172 return Ok(());
173 }
174 self.remove_container(name).await
175 }
176
177 async fn container_exists(&self, name: &str) -> bool {
179 self.command()
180 .args(["container", "inspect", name])
181 .stdout(Stdio::null())
182 .stderr(Stdio::null())
183 .status()
184 .await
185 .is_ok_and(|status| status.success())
186 }
187
188 pub async fn remove_volume(&self, name: &str) -> Result<()> {
190 self.status(["volume", "rm", "--force", name]).await
191 }
192
193 pub async fn remove_network(&self, name: &str) -> Result<()> {
195 self.status(["network", "rm", name]).await
196 }
197
198 pub async fn create_volume(
200 &self,
201 name: &str,
202 labels: &[(String, String)],
203 options: &[(String, String)],
204 ) -> Result<()> {
205 let mut args = vec!["volume".to_string(), "create".to_string(), name.to_string()];
206 for (key, value) in labels {
207 args.push("--label".to_string());
208 args.push(format!("{key}={value}"));
209 }
210 for (key, value) in options {
211 args.push("--opt".to_string());
212 args.push(format!("{key}={value}"));
213 }
214 self.status_owned(args).await
215 }
216
217 pub async fn create_network(&self, name: &str, config: &CreateNetworkOptions) -> Result<()> {
219 let mut args = vec![
220 "network".to_string(),
221 "create".to_string(),
222 "--driver".to_string(),
223 "bridge".to_string(),
224 ];
225 if config.internal {
226 args.push("--internal".to_string());
227 }
228 if config.enable_ipv6 {
229 args.push("--ipv6".to_string());
230 }
231 if config.attachable {
232 args.push("--attachable".to_string());
233 }
234 for (key, value) in &config.labels {
235 args.push("--label".to_string());
236 args.push(format!("{key}={value}"));
237 }
238 for (key, value) in &config.options {
239 args.push("--opt".to_string());
240 args.push(format!("{key}={value}"));
241 }
242 for (subnet, gateway, ip_range) in &config.ipam {
243 if !subnet.is_empty() {
244 args.push("--subnet".to_string());
245 args.push(subnet.clone());
246 }
247 if !gateway.is_empty() {
248 args.push("--gateway".to_string());
249 args.push(gateway.clone());
250 }
251 if !ip_range.is_empty() {
252 args.push("--ip-range".to_string());
253 args.push(ip_range.clone());
254 }
255 }
256 args.push(name.to_string());
257 self.status_owned(args).await
258 }
259
260 pub async fn create_container<I, S>(&self, args: I) -> Result<String>
262 where
263 I: IntoIterator<Item = S>,
264 S: AsRef<str>,
265 {
266 let output = self.output(["container", "create"], args).await?;
267 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
268 }
269
270 pub async fn connect_network(
272 &self,
273 network: &str,
274 container: &str,
275 aliases: &[String],
276 ) -> Result<()> {
277 let mut args = vec!["network".to_string(), "connect".to_string()];
278 for alias in aliases {
279 args.push("--alias".to_string());
280 args.push(alias.clone());
281 }
282 args.push(network.to_string());
283 args.push(container.to_string());
284 self.status_owned(args).await
285 }
286
287 pub async fn create_helper_container(&self, name: &str, volume_name: &str) -> Result<String> {
289 self.create_container([
290 "--name",
291 name,
292 "--mount",
293 &format!("type=volume,src={volume_name},dst=/volume"),
294 HELPER_IMAGE_REFERENCE,
295 "/helper",
296 ])
297 .await
298 }
299
300 pub async fn ensure_helper_image(&self) -> Result<()> {
302 let status = Command::new(&self.binary)
303 .arg("--host")
304 .arg(self.host_arg())
305 .args(["image", "inspect", HELPER_IMAGE_REFERENCE])
306 .env("DOCKER_CONFIG", self.isolated_config.path())
307 .env("DOCKER_CLI_HINTS", "false")
308 .stdout(Stdio::null())
309 .stderr(Stdio::null())
310 .status()
311 .await?;
312 if status.success() {
313 return Ok(());
314 }
315
316 let mut child = self
317 .command()
318 .args(["image", "import", "-", HELPER_IMAGE_REFERENCE])
319 .stdin(Stdio::piped())
320 .stdout(Stdio::null())
321 .stderr(Stdio::piped())
322 .spawn()?;
323 if let Some(mut stdin) = child.stdin.take() {
324 stdin.write_all(&empty_tar_bytes()).await?;
325 }
326 wait_for_success(child, "docker image import").await
327 }
328
329 pub async fn pipe_save_into(
336 &self,
337 target: &Self,
338 references: &[String],
339 ) -> Result<ImageTransfer> {
340 if references.is_empty() {
341 return Err(MigrationError::InvalidPlan(
342 "image plan has no export references".into(),
343 ));
344 }
345
346 let mut save = self
347 .command()
348 .args(["image", "save"])
349 .args(references)
350 .stdout(Stdio::piped())
351 .stderr(Stdio::piped())
352 .spawn()?;
353 let mut load = target
354 .command()
355 .args(["image", "load", "--quiet"])
356 .stdin(Stdio::piped())
357 .stdout(Stdio::piped())
358 .stderr(Stdio::piped())
359 .spawn()?;
360
361 let mut save_stdout = save
362 .stdout
363 .take()
364 .ok_or_else(|| MigrationError::Docker("docker save stdout missing".into()))?;
365 let mut load_stdin = load
366 .stdin
367 .take()
368 .ok_or_else(|| MigrationError::Docker("docker load stdin missing".into()))?;
369
370 let copy_task = tokio::spawn(async move {
371 let copied = tokio::io::copy(&mut save_stdout, &mut load_stdin).await?;
372 load_stdin.shutdown().await?;
373 Ok::<u64, std::io::Error>(copied)
374 });
375 let save_stderr = tokio::spawn(take_stderr(save.stderr.take()));
377 let load_stderr = tokio::spawn(take_stderr(load.stderr.take()));
378 let load_stdout = tokio::spawn(take_pipe(load.stdout.take(), "docker load stdout"));
379
380 let save_status = save.wait().await?;
381 let load_status = load.wait().await?;
382 let copied = copy_task
383 .await
384 .map_err(|e| MigrationError::Docker(format!("docker save copy task failed: {e}")))?;
385 let save_stderr = save_stderr.await.map_err(|e| {
386 MigrationError::Docker(format!("docker save stderr task failed: {e}"))
387 })??;
388 let load_stderr = load_stderr.await.map_err(|e| {
389 MigrationError::Docker(format!("docker load stderr task failed: {e}"))
390 })??;
391 let load_stdout = load_stdout.await.map_err(|e| {
392 MigrationError::Docker(format!("docker load stdout task failed: {e}"))
393 })??;
394
395 if !save_status.success() {
398 return Err(MigrationError::Docker(format!(
399 "docker image save failed: {}",
400 save_stderr.trim()
401 )));
402 }
403 if !load_status.success() {
404 return Err(MigrationError::Docker(format!(
405 "docker image load failed: {}",
406 load_stderr.trim()
407 )));
408 }
409 Ok(ImageTransfer {
410 bytes: copied?,
411 loaded_image_id: parse_loaded_image_id(&load_stdout),
412 })
413 }
414
415 pub async fn copy_from_container(
423 &self,
424 container: &str,
425 source_path: &str,
426 ) -> Result<tempfile::NamedTempFile> {
427 let file = tempfile::NamedTempFile::new()?;
428 let path = file.path().to_path_buf();
429 let mut child = self
430 .command()
431 .args([
432 "container",
433 "cp",
434 "--archive",
435 &format!("{container}:{source_path}"),
436 "-",
437 ])
438 .stdout(Stdio::piped())
439 .stderr(Stdio::piped())
440 .spawn()?;
441
442 let mut stdout = child
443 .stdout
444 .take()
445 .ok_or_else(|| MigrationError::Docker("docker cp stdout missing".into()))?;
446 let mut dest = tokio::fs::File::create(&path).await?;
447 let stdout_task =
448 tokio::spawn(async move { tokio::io::copy(&mut stdout, &mut dest).await.map(|_| ()) });
449 let stderr_task = tokio::spawn(take_stderr(child.stderr.take()));
451 let status = child.wait().await?;
452 stdout_task
453 .await
454 .map_err(|e| MigrationError::Docker(format!("docker cp copy task failed: {e}")))??;
455 let stderr = stderr_task
456 .await
457 .map_err(|e| MigrationError::Docker(format!("docker cp stderr task failed: {e}")))??;
458 if !status.success() {
459 return Err(MigrationError::Docker(format!(
460 "docker container cp failed: {}",
461 stderr.trim()
462 )));
463 }
464 Ok(file)
465 }
466
467 pub async fn copy_to_container(
472 &self,
473 source_archive: &Path,
474 container: &str,
475 target_path: &str,
476 ) -> Result<()> {
477 let mut child = self
478 .command()
479 .args([
480 "container",
481 "cp",
482 "--archive",
483 "-",
484 &format!("{container}:{target_path}"),
485 ])
486 .stdin(Stdio::piped())
487 .stdout(Stdio::null())
488 .stderr(Stdio::piped())
489 .spawn()?;
490 let mut stdin = child
491 .stdin
492 .take()
493 .ok_or_else(|| MigrationError::Docker("docker cp stdin missing".into()))?;
494 let mut source = tokio::fs::File::open(source_archive).await?;
495 let write_task = tokio::spawn(async move {
496 tokio::io::copy(&mut source, &mut stdin).await?;
497 stdin.shutdown().await
498 });
499 let stderr_task = tokio::spawn(take_stderr(child.stderr.take()));
501 let status = child.wait().await?;
502 write_task
503 .await
504 .map_err(|e| MigrationError::Docker(format!("docker cp write task failed: {e}")))??;
505 let stderr = stderr_task
506 .await
507 .map_err(|e| MigrationError::Docker(format!("docker cp stderr task failed: {e}")))??;
508 if !status.success() {
509 return Err(MigrationError::Docker(format!(
510 "docker container cp failed: {}",
511 stderr.trim()
512 )));
513 }
514 Ok(())
515 }
516
517 async fn inspect_many<T>(&self, noun: &str, ids: &[String]) -> Result<Vec<T>>
518 where
519 T: serde::de::DeserializeOwned,
520 {
521 if ids.is_empty() {
522 return Ok(Vec::new());
523 }
524 let mut args = vec![noun.to_string(), "inspect".to_string()];
525 args.extend(ids.iter().cloned());
526 self.json_array_owned(args).await
527 }
528
529 async fn json_object<T>(&self, args: &[&str]) -> Result<T>
530 where
531 T: serde::de::DeserializeOwned,
532 {
533 let output = self
534 .output_owned(args.iter().map(ToString::to_string).collect())
535 .await?;
536 serde_json::from_slice(&output.stdout).map_err(Into::into)
537 }
538
539 async fn json_array_owned<T>(&self, args: Vec<String>) -> Result<Vec<T>>
540 where
541 T: serde::de::DeserializeOwned,
542 {
543 let output = self.output_owned(args).await?;
544 serde_json::from_slice(&output.stdout).map_err(Into::into)
545 }
546
547 async fn lines(&self, args: &[&str]) -> Result<Vec<String>> {
548 let output = self
549 .output_owned(args.iter().map(ToString::to_string).collect())
550 .await?;
551 Ok(String::from_utf8_lossy(&output.stdout)
552 .lines()
553 .map(str::trim)
554 .filter(|line| !line.is_empty())
555 .map(ToOwned::to_owned)
556 .collect())
557 }
558
559 async fn status<I, S>(&self, args: I) -> Result<()>
560 where
561 I: IntoIterator<Item = S>,
562 S: AsRef<str>,
563 {
564 self.output_owned(
565 args.into_iter()
566 .map(|arg| arg.as_ref().to_string())
567 .collect(),
568 )
569 .await
570 .map(|_| ())
571 }
572
573 async fn status_owned(&self, args: Vec<String>) -> Result<()> {
574 self.output_owned(args).await.map(|_| ())
575 }
576
577 async fn output<I, S, J, T>(&self, prefix: I, rest: J) -> Result<std::process::Output>
578 where
579 I: IntoIterator<Item = S>,
580 S: AsRef<str>,
581 J: IntoIterator<Item = T>,
582 T: AsRef<str>,
583 {
584 let mut args: Vec<String> = prefix
585 .into_iter()
586 .map(|item| item.as_ref().to_string())
587 .collect();
588 args.extend(rest.into_iter().map(|item| item.as_ref().to_string()));
589 self.output_owned(args).await
590 }
591
592 async fn output_owned(&self, args: Vec<String>) -> Result<std::process::Output> {
593 let output = self
594 .command()
595 .args(args)
596 .output()
597 .await
598 .map_err(|e| MigrationError::Docker(format!("failed to run docker: {e}")))?;
599 if output.status.success() {
600 return Ok(output);
601 }
602 Err(MigrationError::Docker(
603 String::from_utf8_lossy(&output.stderr).trim().to_string(),
604 ))
605 }
606
607 fn command(&self) -> Command {
608 let mut command = Command::new(&self.binary);
609 command
610 .arg("--host")
611 .arg(self.host_arg())
612 .env("DOCKER_CONFIG", self.isolated_config.path())
613 .env("DOCKER_CLI_HINTS", "false")
614 .env("NO_COLOR", "1")
615 .kill_on_drop(true);
616 command
617 }
618
619 fn host_arg(&self) -> String {
620 format!("unix://{}", self.socket_path.display())
621 }
622}
623
624async fn wait_for_success(mut child: Child, context: &str) -> Result<()> {
625 let stderr = take_stderr(child.stderr.take()).await?;
626 let status = child.wait().await?;
627 if status.success() {
628 Ok(())
629 } else {
630 Err(MigrationError::Docker(format!(
631 "{context} failed: {}",
632 stderr.trim()
633 )))
634 }
635}
636
637async fn take_pipe<R>(pipe: Option<R>, what: &'static str) -> Result<String>
639where
640 R: tokio::io::AsyncRead + Unpin + Send,
641{
642 let mut pipe = pipe.ok_or_else(|| MigrationError::Docker(format!("{what} pipe missing")))?;
643 let mut buf = Vec::new();
644 pipe.read_to_end(&mut buf).await?;
645 Ok(String::from_utf8_lossy(&buf).to_string())
646}
647
648async fn take_stderr(stderr: Option<tokio::process::ChildStderr>) -> Result<String> {
649 take_pipe(stderr, "docker stderr").await
650}
651
652fn resolve_docker_binary() -> Option<PathBuf> {
653 if let Some(path) = find_in_path("docker") {
654 return Some(path);
655 }
656
657 let home = dirs::home_dir()?;
658 let candidates = [
659 home.join(".arcbox/bin/docker"),
660 home.join(".arcbox/runtime/bin/docker"),
661 PathBuf::from("/opt/homebrew/bin/docker"),
662 PathBuf::from("/usr/local/bin/docker"),
663 PathBuf::from("/Applications/Docker.app/Contents/Resources/bin/docker"),
664 ];
665 candidates.into_iter().find(|path: &PathBuf| path.is_file())
666}
667
668fn find_in_path(binary: &str) -> Option<PathBuf> {
669 let path_var = std::env::var_os("PATH")?;
670 for directory in std::env::split_paths(&path_var) {
671 let candidate = directory.join(binary);
672 if candidate.is_file() {
673 return Some(candidate);
674 }
675 }
676 None
677}
678
679fn empty_tar_bytes() -> Vec<u8> {
680 vec![0; 1024]
681}
682
683#[cfg(test)]
684mod tests {
685 use super::{empty_tar_bytes, find_in_path, parse_loaded_image_id};
686
687 #[test]
688 fn untagged_load_reports_the_assigned_id() {
689 let stdout = "Loaded image ID: sha256:1c8e3d999f27cb62d9714d9226751e16bacab4227\n";
691 assert_eq!(
692 parse_loaded_image_id(stdout).as_deref(),
693 Some("sha256:1c8e3d999f27cb62d9714d9226751e16bacab4227")
694 );
695 }
696
697 #[test]
698 fn tagged_load_reports_no_id_to_remap() {
699 let stdout = "Loaded image: myapp:dev\nLoaded image: myapp:latest\n";
702 assert_eq!(parse_loaded_image_id(stdout), None);
703 assert_eq!(parse_loaded_image_id(""), None);
704 assert_eq!(parse_loaded_image_id("Loaded image ID: \n"), None);
705 }
706
707 #[test]
708 fn empty_tar_has_two_zero_blocks() {
709 assert_eq!(empty_tar_bytes().len(), 1024);
710 assert!(empty_tar_bytes().iter().all(|byte| *byte == 0));
711 }
712
713 #[test]
714 fn path_lookup_handles_missing_binary() {
715 assert!(find_in_path("definitely-not-a-real-binary").is_none());
716 }
717}