Skip to main content

systemprompt_cloud/
docker.rs

1//! Docker CLI invocations behind a stubbable process-execution seam.
2//!
3//! [`DockerCli`] is the single place the platform shells out to `docker`. The
4//! deploy-oriented operations (build, login, push) carry their own error
5//! mapping; the raw [`DockerCli::output`] / [`DockerCli::status`] primitives
6//! exist for callers that interpret exit codes and captured output themselves
7//! (container inspection, compose lifecycle, `docker exec psql`). All process
8//! execution flows through [`CommandRunner`], so tests can substitute a stub
9//! instead of spawning real processes.
10//!
11//! `build_image` first proves the daemon is reachable through the `docker`
12//! binary that `PATH` resolves. A wrapper shim ahead of the real binary (a
13//! `credsStore` helper that is not installed, a desktop-app shim on a headless
14//! box) fails `docker build` with a message that names neither, so the
15//! preflight failure reports the resolved binary and the configured
16//! credential store alongside the daemon's own error.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use std::io;
22use std::path::{Path, PathBuf};
23use std::process::{Command, ExitStatus, Output, Stdio};
24
25use crate::error::{CloudError, CloudResult};
26
27#[derive(Debug, Clone)]
28pub struct CommandSpec {
29    pub program: String,
30    pub args: Vec<String>,
31    pub current_dir: Option<PathBuf>,
32}
33
34impl CommandSpec {
35    pub fn docker<I, S>(args: I) -> Self
36    where
37        I: IntoIterator<Item = S>,
38        S: Into<String>,
39    {
40        Self {
41            program: "docker".to_owned(),
42            args: args.into_iter().map(Into::into).collect(),
43            current_dir: None,
44        }
45    }
46
47    #[must_use]
48    pub fn rendered(&self) -> String {
49        format!("{} {}", self.program, self.args.join(" "))
50    }
51}
52
53pub trait CommandRunner: Send + Sync {
54    fn output(&self, spec: &CommandSpec) -> io::Result<Output>;
55    fn status(&self, spec: &CommandSpec) -> io::Result<ExitStatus>;
56    fn status_with_stdin(&self, spec: &CommandSpec, stdin: &[u8]) -> io::Result<ExitStatus>;
57}
58
59#[derive(Debug, Clone, Copy, Default)]
60pub struct SystemCommandRunner;
61
62impl SystemCommandRunner {
63    fn command(spec: &CommandSpec) -> Command {
64        let mut command = Command::new(&spec.program);
65        command.args(&spec.args);
66        if let Some(dir) = &spec.current_dir {
67            command.current_dir(dir);
68        }
69        command
70    }
71}
72
73impl CommandRunner for SystemCommandRunner {
74    fn output(&self, spec: &CommandSpec) -> io::Result<Output> {
75        Self::command(spec).output()
76    }
77
78    fn status(&self, spec: &CommandSpec) -> io::Result<ExitStatus> {
79        Self::command(spec).status()
80    }
81
82    fn status_with_stdin(&self, spec: &CommandSpec, stdin: &[u8]) -> io::Result<ExitStatus> {
83        let mut command = Self::command(spec);
84        command.stdin(Stdio::piped());
85        let mut child = command.spawn()?;
86        if let Some(mut handle) = child.stdin.take() {
87            io::Write::write_all(&mut handle, stdin)?;
88        }
89        child.wait()
90    }
91}
92
93fn configured_creds_store() -> String {
94    let Some(config) = dirs::home_dir().map(|home| home.join(".docker").join("config.json")) else {
95        return "unknown (no home directory)".to_owned();
96    };
97    let Ok(raw) = std::fs::read_to_string(&config) else {
98        return format!("none ({} absent)", config.display());
99    };
100    match serde_json::from_str::<serde_json::Value>(&raw) {
101        Ok(value) => value
102            .get("credsStore")
103            .and_then(serde_json::Value::as_str)
104            .map_or_else(|| "none".to_owned(), ToOwned::to_owned),
105        Err(e) => format!("unreadable ({}: {e})", config.display()),
106    }
107}
108
109pub struct DockerCli {
110    runner: Box<dyn CommandRunner>,
111}
112
113impl std::fmt::Debug for DockerCli {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("DockerCli").finish_non_exhaustive()
116    }
117}
118
119impl Default for DockerCli {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl DockerCli {
126    #[must_use]
127    pub fn new() -> Self {
128        Self {
129            runner: Box::new(SystemCommandRunner),
130        }
131    }
132
133    #[must_use]
134    pub const fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
135        Self { runner }
136    }
137
138    pub fn preflight_daemon(&self) -> CloudResult<String> {
139        let spec = CommandSpec::docker(["version", "--format", "{{.Server.Version}}"]);
140        let outcome = self.runner.output(&spec);
141        let failure = match outcome {
142            Ok(output) if output.status.success() => {
143                return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
144            },
145            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_owned(),
146            Err(e) => e.to_string(),
147        };
148
149        Err(CloudError::docker(format!(
150            "Docker daemon unreachable via `{}`: {}\n  docker binary: {}\n  credsStore: {}",
151            spec.rendered(),
152            if failure.is_empty() {
153                "no output".to_owned()
154            } else {
155                failure
156            },
157            self.resolved_docker_binary(),
158            configured_creds_store(),
159        )))
160    }
161
162    fn resolved_docker_binary(&self) -> String {
163        let spec = if cfg!(windows) {
164            CommandSpec {
165                program: "where".to_owned(),
166                args: vec!["docker".to_owned()],
167                current_dir: None,
168            }
169        } else {
170            CommandSpec {
171                program: "sh".to_owned(),
172                args: vec!["-c".to_owned(), "command -v docker".to_owned()],
173                current_dir: None,
174            }
175        };
176        match self.runner.output(&spec) {
177            Ok(output) if output.status.success() => {
178                let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
179                if path.is_empty() {
180                    "not on PATH".to_owned()
181                } else {
182                    path
183                }
184            },
185            Ok(_) => "not on PATH".to_owned(),
186            Err(e) => format!("unresolved ({e})"),
187        }
188    }
189
190    pub fn build_image(
191        &self,
192        context_dir: &Path,
193        dockerfile: &Path,
194        image: &str,
195    ) -> CloudResult<()> {
196        self.preflight_daemon()?;
197
198        let dockerfile_arg = dockerfile.to_string_lossy().into_owned();
199        let mut spec = CommandSpec::docker([
200            "build",
201            "--no-cache",
202            "-f",
203            dockerfile_arg.as_str(),
204            "-t",
205            image,
206            ".",
207        ]);
208        spec.current_dir = Some(context_dir.to_path_buf());
209
210        let status = self.runner.status(&spec).map_err(|e| {
211            CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
212        })?;
213
214        if !status.success() {
215            return Err(CloudError::docker(format!(
216                "Command failed: {}",
217                spec.rendered()
218            )));
219        }
220
221        Ok(())
222    }
223
224    pub fn login(&self, registry: &str, username: &str, token: &str) -> CloudResult<()> {
225        let spec = CommandSpec::docker(["login", registry, "-u", username, "--password-stdin"]);
226
227        let status = self
228            .runner
229            .status_with_stdin(&spec, token.as_bytes())
230            .map_err(|e| {
231                CloudError::docker_with(format!("failed to spawn `docker login {registry}`"), e)
232            })?;
233
234        if !status.success() {
235            return Err(CloudError::docker("Docker login failed"));
236        }
237
238        Ok(())
239    }
240
241    pub fn push(&self, image: &str) -> CloudResult<()> {
242        let spec = CommandSpec::docker(["push", image]);
243
244        let status = self.runner.status(&spec).map_err(|e| {
245            CloudError::docker_with(format!("failed to spawn `docker push {image}`"), e)
246        })?;
247
248        if !status.success() {
249            return Err(CloudError::docker(format!(
250                "Docker push failed for image: {}",
251                image
252            )));
253        }
254
255        Ok(())
256    }
257
258    pub fn output(&self, args: &[&str]) -> io::Result<Output> {
259        self.runner
260            .output(&CommandSpec::docker(args.iter().copied()))
261    }
262
263    pub fn status(&self, args: &[&str]) -> io::Result<ExitStatus> {
264        self.runner
265            .status(&CommandSpec::docker(args.iter().copied()))
266    }
267}