Skip to main content

arcbox_migration/
runner.rs

1//! Docker CLI transport used by migration planning and execution.
2
3use 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/// Docker CLI runner bound to a Unix socket.
19#[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/// Network creation options supported by the CLI transport.
36#[derive(Debug, Clone)]
37pub struct CreateNetworkOptions {
38    /// Whether the network is internal.
39    pub internal: bool,
40    /// Whether IPv6 is enabled.
41    pub enable_ipv6: bool,
42    /// Whether the network is attachable.
43    pub attachable: bool,
44    /// Network labels.
45    pub labels: Vec<(String, String)>,
46    /// Driver options.
47    pub options: Vec<(String, String)>,
48    /// IPAM subnet tuples.
49    pub ipam: Vec<(String, String, String)>,
50}
51
52impl DockerCliRunner {
53    /// Creates a new CLI runner for the provided Docker socket.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the `docker` binary cannot be located.
58    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    /// Returns the socket path used by this runner.
69    #[must_use]
70    pub fn socket_path(&self) -> &Path {
71        &self.socket_path
72    }
73
74    /// Returns the helper image reference used for temporary volume containers.
75    #[must_use]
76    pub const fn helper_image_reference(&self) -> &'static str {
77        HELPER_IMAGE_REFERENCE
78    }
79
80    /// Returns Docker daemon info.
81    pub async fn info(&self) -> Result<DockerInfo> {
82        self.json_object(&["info", "--format", "{{json .}}"]).await
83    }
84
85    /// Returns all image inspect payloads.
86    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    /// Returns all volume inspect payloads.
92    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    /// Returns all user-defined network inspect payloads.
98    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    /// Returns all container inspect payloads, including stopped containers.
106    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    /// Stops a container.
115    pub async fn stop_container(&self, id: &str) -> Result<()> {
116        self.status(["container", "stop", "--time", "30", id]).await
117    }
118
119    /// Removes a container forcibly.
120    pub async fn remove_container(&self, id: &str) -> Result<()> {
121        self.status(["container", "rm", "--force", "--volumes", id])
122            .await
123    }
124
125    /// Removes a volume.
126    pub async fn remove_volume(&self, name: &str) -> Result<()> {
127        self.status(["volume", "rm", "--force", name]).await
128    }
129
130    /// Removes a network.
131    pub async fn remove_network(&self, name: &str) -> Result<()> {
132        self.status(["network", "rm", name]).await
133    }
134
135    /// Creates a volume with labels and options.
136    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    /// Creates a network using supported bridge-network flags.
155    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    /// Creates a container and returns its ID.
198    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    /// Connects a container to an additional network.
208    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    /// Creates a helper container mounting the provided volume at `/volume`.
225    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    /// Ensures the helper image exists by importing an empty tar archive when needed.
238    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    /// Saves an image to a tempfile.
267    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    /// Loads an image from a tempfile.
282    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    /// Streams a container path into a tempfile via `docker cp`.
294    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        // Drain stderr concurrently to prevent pipe buffer deadlocks.
321        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    /// Streams a tar archive tempfile into a container via `docker cp`.
339    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        // Drain stderr concurrently to prevent pipe buffer deadlocks.
367        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}