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
11use std::io;
12use std::path::{Path, PathBuf};
13use std::process::{Command, ExitStatus, Output, Stdio};
14
15use crate::error::{CloudError, CloudResult};
16
17#[derive(Debug, Clone)]
18pub struct CommandSpec {
19    pub program: String,
20    pub args: Vec<String>,
21    pub current_dir: Option<PathBuf>,
22}
23
24impl CommandSpec {
25    pub fn docker<I, S>(args: I) -> Self
26    where
27        I: IntoIterator<Item = S>,
28        S: Into<String>,
29    {
30        Self {
31            program: "docker".to_owned(),
32            args: args.into_iter().map(Into::into).collect(),
33            current_dir: None,
34        }
35    }
36
37    #[must_use]
38    pub fn rendered(&self) -> String {
39        format!("{} {}", self.program, self.args.join(" "))
40    }
41}
42
43pub trait CommandRunner: Send + Sync {
44    fn output(&self, spec: &CommandSpec) -> io::Result<Output>;
45    fn status(&self, spec: &CommandSpec) -> io::Result<ExitStatus>;
46    fn status_with_stdin(&self, spec: &CommandSpec, stdin: &[u8]) -> io::Result<ExitStatus>;
47}
48
49#[derive(Debug, Clone, Copy, Default)]
50pub struct SystemCommandRunner;
51
52impl SystemCommandRunner {
53    fn command(spec: &CommandSpec) -> Command {
54        let mut command = Command::new(&spec.program);
55        command.args(&spec.args);
56        if let Some(dir) = &spec.current_dir {
57            command.current_dir(dir);
58        }
59        command
60    }
61}
62
63impl CommandRunner for SystemCommandRunner {
64    fn output(&self, spec: &CommandSpec) -> io::Result<Output> {
65        Self::command(spec).output()
66    }
67
68    fn status(&self, spec: &CommandSpec) -> io::Result<ExitStatus> {
69        Self::command(spec).status()
70    }
71
72    fn status_with_stdin(&self, spec: &CommandSpec, stdin: &[u8]) -> io::Result<ExitStatus> {
73        let mut command = Self::command(spec);
74        command.stdin(Stdio::piped());
75        let mut child = command.spawn()?;
76        if let Some(mut handle) = child.stdin.take() {
77            io::Write::write_all(&mut handle, stdin)?;
78        }
79        child.wait()
80    }
81}
82
83pub struct DockerCli {
84    runner: Box<dyn CommandRunner>,
85}
86
87impl std::fmt::Debug for DockerCli {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("DockerCli").finish_non_exhaustive()
90    }
91}
92
93impl Default for DockerCli {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl DockerCli {
100    #[must_use]
101    pub fn new() -> Self {
102        Self {
103            runner: Box::new(SystemCommandRunner),
104        }
105    }
106
107    #[must_use]
108    pub const fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
109        Self { runner }
110    }
111
112    pub fn build_image(
113        &self,
114        context_dir: &Path,
115        dockerfile: &Path,
116        image: &str,
117    ) -> CloudResult<()> {
118        let dockerfile_arg = dockerfile.to_string_lossy().into_owned();
119        let mut spec = CommandSpec::docker([
120            "build",
121            "--no-cache",
122            "-f",
123            dockerfile_arg.as_str(),
124            "-t",
125            image,
126            ".",
127        ]);
128        spec.current_dir = Some(context_dir.to_path_buf());
129
130        let status = self.runner.status(&spec).map_err(|e| {
131            CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
132        })?;
133
134        if !status.success() {
135            return Err(CloudError::docker(format!(
136                "Command failed: {}",
137                spec.rendered()
138            )));
139        }
140
141        Ok(())
142    }
143
144    pub fn login(&self, registry: &str, username: &str, token: &str) -> CloudResult<()> {
145        let spec = CommandSpec::docker(["login", registry, "-u", username, "--password-stdin"]);
146
147        let status = self
148            .runner
149            .status_with_stdin(&spec, token.as_bytes())
150            .map_err(|e| {
151                CloudError::docker_with(format!("failed to spawn `docker login {registry}`"), e)
152            })?;
153
154        if !status.success() {
155            return Err(CloudError::docker("Docker login failed"));
156        }
157
158        Ok(())
159    }
160
161    pub fn push(&self, image: &str) -> CloudResult<()> {
162        let spec = CommandSpec::docker(["push", image]);
163
164        let status = self.runner.status(&spec).map_err(|e| {
165            CloudError::docker_with(format!("failed to spawn `docker push {image}`"), e)
166        })?;
167
168        if !status.success() {
169            return Err(CloudError::docker(format!(
170                "Docker push failed for image: {}",
171                image
172            )));
173        }
174
175        Ok(())
176    }
177
178    pub fn output(&self, args: &[&str]) -> io::Result<Output> {
179        self.runner
180            .output(&CommandSpec::docker(args.iter().copied()))
181    }
182
183    pub fn status(&self, args: &[&str]) -> io::Result<ExitStatus> {
184        self.runner
185            .status(&CommandSpec::docker(args.iter().copied()))
186    }
187}