Skip to main content

xianyun_backends/docker/
mod.rs

1//! Thin backend over the `docker` CLI, for caproni features that need to
2//! talk to a Docker daemon (e.g. docker-in-docker CI, or operating on
3//! containers managed by k3d).
4//!
5//! Ported from `internal/backends/docker/{interface,client}.go`.
6
7use std::sync::Arc;
8
9use xianyun_executor::exec::{CommandRunner, ExecError};
10
11/// Ported from the `Backend` interface in `interface.go`.
12pub trait Backend {
13    /// Reports whether the named container exists. A missing container
14    /// returns `Ok(false)`; only true execution errors yield `Err`.
15    fn inspect(&self, container: &str) -> Result<bool, ExecError>;
16
17    /// Runs `docker exec <container> <args...>` and returns the captured
18    /// stdout.
19    fn exec(&self, container: &str, args: &[&str]) -> Result<String, ExecError>;
20
21    /// Copies a single host file to the given path inside the container
22    /// via `docker cp`.
23    fn copy_to_container(
24        &self,
25        container: &str,
26        host_path: &str,
27        container_path: &str,
28    ) -> Result<(), ExecError>;
29}
30
31/// Ported from `Client` in `client.go`.
32pub struct Client {
33    executor: Arc<dyn CommandRunner + Send + Sync>,
34}
35
36impl Client {
37    /// Ported from `NewClient`.
38    pub fn new(executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
39        Self { executor }
40    }
41}
42
43impl Backend for Client {
44    fn inspect(&self, container: &str) -> Result<bool, ExecError> {
45        self.executor.check(
46            "docker",
47            &[
48                "inspect".into(),
49                "--format={{.Id}}".into(),
50                container.into(),
51            ],
52        )
53    }
54
55    fn exec(&self, container: &str, args: &[&str]) -> Result<String, ExecError> {
56        let mut exec_args = vec!["exec".to_string(), container.to_string()];
57        exec_args.extend(args.iter().map(std::string::ToString::to_string));
58
59        let out = self.executor.read_stdout("docker", &exec_args)?;
60
61        Ok(out.trim_end_matches('\n').to_string())
62    }
63
64    fn copy_to_container(
65        &self,
66        container: &str,
67        host_path: &str,
68        container_path: &str,
69    ) -> Result<(), ExecError> {
70        let dest = format!("{container}:{container_path}");
71
72        self.executor.exec_streamed_command(
73            None,
74            "docker",
75            &["cp".to_string(), host_path.to_string(), dest],
76        )
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use xianyun_testutil::MockCommandRunner;
84
85    // --- Ported from client_test.go: TestClient ---
86
87    #[test]
88    fn inspect_returns_true_when_container_exists() {
89        let mock = MockCommandRunner::new();
90        mock.expect_check(
91            "docker",
92            &["inspect", "--format={{.Id}}", "my-container"],
93            Ok(true),
94        );
95
96        let client = Client::new(Arc::new(mock));
97
98        let ok = client.inspect("my-container").unwrap();
99        assert!(ok);
100    }
101
102    #[test]
103    fn exec_runs_docker_exec_and_returns_trimmed_stdout() {
104        let mock = MockCommandRunner::new();
105        mock.expect_read_stdout(
106            "docker",
107            &["exec", "my-container", "cat", "/etc/hostname"],
108            Ok("my-container\n".to_string()),
109        );
110
111        let client = Client::new(Arc::new(mock));
112
113        let out = client
114            .exec("my-container", &["cat", "/etc/hostname"])
115            .unwrap();
116        assert_eq!(out, "my-container");
117    }
118
119    #[test]
120    fn copy_to_container_runs_docker_cp() {
121        let mock = MockCommandRunner::new();
122        mock.expect_exec_streamed_command(
123            "docker",
124            &["cp", "/host/file.txt", "my-container:/dest/file.txt"],
125            Ok(()),
126        );
127
128        let client = Client::new(Arc::new(mock));
129
130        client
131            .copy_to_container("my-container", "/host/file.txt", "/dest/file.txt")
132            .unwrap();
133    }
134
135    #[test]
136    fn inspect_wraps_execution_errors() {
137        // Not present as a separate Go case in the ported table (the Go
138        // table only exercises the happy path per method), but kept here
139        // as a placeholder so the error-wrapping behavior documented on
140        // `Backend::inspect` has a red-phase test to un-ignore in Phase 4.
141        let mock = MockCommandRunner::new();
142        mock.expect_check(
143            "docker",
144            &["inspect", "--format={{.Id}}", "missing-container"],
145            Err(ExecError::RunFailed(
146                "docker daemon unreachable".to_string(),
147            )),
148        );
149
150        let client = Client::new(Arc::new(mock));
151
152        let err = client.inspect("missing-container").unwrap_err();
153        assert!(matches!(err, ExecError::RunFailed(_)));
154    }
155}