1use 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}