Skip to main content

systemprompt_cloud/docker/
mod.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;
23use std::process::{ExitStatus, Output};
24
25use crate::error::{CloudError, CloudResult};
26
27mod command;
28
29pub use command::{CommandRunner, CommandSpec, SystemCommandRunner};
30
31fn configured_creds_store() -> String {
32    let Some(config) = dirs::home_dir().map(|home| home.join(".docker").join("config.json")) else {
33        return "unknown (no home directory)".to_owned();
34    };
35    let Ok(raw) = std::fs::read_to_string(&config) else {
36        return format!("none ({} absent)", config.display());
37    };
38    match serde_json::from_str::<serde_json::Value>(&raw) {
39        Ok(value) => value
40            .get("credsStore")
41            .and_then(serde_json::Value::as_str)
42            .map_or_else(|| "none".to_owned(), ToOwned::to_owned),
43        Err(e) => format!("unreadable ({}: {e})", config.display()),
44    }
45}
46
47pub struct DockerCli {
48    runner: Box<dyn CommandRunner>,
49}
50
51impl std::fmt::Debug for DockerCli {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("DockerCli").finish_non_exhaustive()
54    }
55}
56
57impl Default for DockerCli {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl DockerCli {
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            runner: Box::new(SystemCommandRunner),
68        }
69    }
70
71    #[must_use]
72    pub const fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
73        Self { runner }
74    }
75
76    pub fn preflight_daemon(&self) -> CloudResult<String> {
77        let spec = CommandSpec::docker(["version", "--format", "{{.Server.Version}}"]);
78        let outcome = self.runner.output(&spec);
79        let failure = match outcome {
80            Ok(output) if output.status.success() => {
81                return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
82            },
83            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_owned(),
84            Err(e) => e.to_string(),
85        };
86
87        Err(CloudError::docker(format!(
88            "Docker daemon unreachable via `{}`: {}\n  docker binary: {}\n  credsStore: {}",
89            spec.rendered(),
90            if failure.is_empty() {
91                "no output".to_owned()
92            } else {
93                failure
94            },
95            self.resolved_docker_binary(),
96            configured_creds_store(),
97        )))
98    }
99
100    fn resolved_docker_binary(&self) -> String {
101        let spec = if cfg!(windows) {
102            CommandSpec {
103                program: "where".to_owned(),
104                args: vec!["docker".to_owned()],
105                current_dir: None,
106            }
107        } else {
108            CommandSpec {
109                program: "sh".to_owned(),
110                args: vec!["-c".to_owned(), "command -v docker".to_owned()],
111                current_dir: None,
112            }
113        };
114        match self.runner.output(&spec) {
115            Ok(output) if output.status.success() => {
116                let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
117                if path.is_empty() {
118                    "not on PATH".to_owned()
119                } else {
120                    path
121                }
122            },
123            Ok(_) => "not on PATH".to_owned(),
124            Err(e) => format!("unresolved ({e})"),
125        }
126    }
127
128    pub fn build_image(
129        &self,
130        context_dir: &Path,
131        dockerfile: &Path,
132        image: &str,
133    ) -> CloudResult<()> {
134        self.preflight_daemon()?;
135
136        let dockerfile_arg = dockerfile.to_string_lossy().into_owned();
137        let mut spec = CommandSpec::docker([
138            "build",
139            "--no-cache",
140            "-f",
141            dockerfile_arg.as_str(),
142            "-t",
143            image,
144            ".",
145        ]);
146        spec.current_dir = Some(context_dir.to_path_buf());
147
148        let status = self.runner.status(&spec).map_err(|e| {
149            CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
150        })?;
151
152        if !status.success() {
153            return Err(CloudError::docker(format!(
154                "Command failed: {}",
155                spec.rendered()
156            )));
157        }
158
159        Ok(())
160    }
161
162    pub fn login(&self, registry: &str, username: &str, token: &str) -> CloudResult<()> {
163        let spec = CommandSpec::docker(["login", registry, "-u", username, "--password-stdin"]);
164
165        let status = self
166            .runner
167            .status_with_stdin(&spec, token.as_bytes())
168            .map_err(|e| {
169                CloudError::docker_with(format!("failed to spawn `docker login {registry}`"), e)
170            })?;
171
172        if !status.success() {
173            return Err(CloudError::docker("Docker login failed"));
174        }
175
176        Ok(())
177    }
178
179    pub fn push(&self, image: &str) -> CloudResult<()> {
180        let spec = CommandSpec::docker(["push", image]);
181
182        let status = self.runner.status(&spec).map_err(|e| {
183            CloudError::docker_with(format!("failed to spawn `docker push {image}`"), e)
184        })?;
185
186        if !status.success() {
187            return Err(CloudError::docker(format!(
188                "Docker push failed for image: {}",
189                image
190            )));
191        }
192
193        Ok(())
194    }
195
196    pub fn repo_digest(&self, image: &str) -> CloudResult<String> {
197        let spec = CommandSpec::docker([
198            "image",
199            "inspect",
200            "--format",
201            "{{json .RepoDigests}}",
202            image,
203        ]);
204        let output = self.runner.output(&spec).map_err(|e| {
205            CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
206        })?;
207        if !output.status.success() {
208            return Err(CloudError::docker(format!(
209                "Command failed: {}: {}",
210                spec.rendered(),
211                String::from_utf8_lossy(&output.stderr).trim()
212            )));
213        }
214        let digests: Vec<String> = serde_json::from_slice(&output.stdout)?;
215        select_repo_digest(image, &digests).ok_or_else(|| {
216            CloudError::docker(format!(
217                "Image {image} has no registry digest; push it before resolving the digest"
218            ))
219        })
220    }
221
222    pub fn output(&self, args: &[&str]) -> io::Result<Output> {
223        self.runner
224            .output(&CommandSpec::docker(args.iter().copied()))
225    }
226
227    pub fn status(&self, args: &[&str]) -> io::Result<ExitStatus> {
228        self.runner
229            .status(&CommandSpec::docker(args.iter().copied()))
230    }
231}
232
233#[must_use]
234pub fn select_repo_digest(image: &str, repo_digests: &[String]) -> Option<String> {
235    let repository = image_repository(image);
236    repo_digests.iter().find_map(|entry| {
237        let (name, digest) = entry.split_once('@')?;
238        (name == repository).then(|| digest.to_owned())
239    })
240}
241
242#[must_use]
243pub fn image_repository(image: &str) -> &str {
244    let without_digest = image.split_once('@').map_or(image, |(name, _)| name);
245    match without_digest.rsplit_once(':') {
246        Some((name, tag)) if !tag.contains('/') => name,
247        _ => without_digest,
248    }
249}