systemprompt_cloud/docker/
mod.rs1use 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}