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