Skip to main content

opencode_cloud_core/docker/
exec.rs

1//! Container exec wrapper for running commands in containers
2//!
3//! This module provides functions to execute commands inside running Docker
4//! containers, with support for capturing output and providing stdin input.
5//! Used for user management operations like useradd, chpasswd, etc.
6
7use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
8use futures_util::StreamExt;
9use tokio::io::AsyncWriteExt;
10
11use super::{DockerClient, DockerError};
12
13/// Execute a command in a running container and capture output
14///
15/// Creates an exec instance, runs the command, and collects stdout/stderr.
16/// Returns the combined output as a String.
17///
18/// # Arguments
19/// * `client` - Docker client
20/// * `container` - Container name or ID
21/// * `cmd` - Command and arguments to execute
22///
23/// # Example
24/// ```ignore
25/// let output = exec_command(&client, "opencode-cloud", vec!["whoami"]).await?;
26/// ```
27pub async fn exec_command(
28    client: &DockerClient,
29    container: &str,
30    cmd: Vec<&str>,
31) -> Result<String, DockerError> {
32    let exec_config = CreateExecOptions {
33        attach_stdout: Some(true),
34        attach_stderr: Some(true),
35        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
36        user: Some("root".to_string()),
37        ..Default::default()
38    };
39
40    let exec = client
41        .inner()
42        .create_exec(container, exec_config)
43        .await
44        .map_err(|e| DockerError::Container(format!("Failed to create exec: {e}")))?;
45
46    let start_config = StartExecOptions {
47        detach: false,
48        ..Default::default()
49    };
50
51    let mut output = String::new();
52
53    match client
54        .inner()
55        .start_exec(&exec.id, Some(start_config))
56        .await
57        .map_err(|e| DockerError::Container(format!("Failed to start exec: {e}")))?
58    {
59        StartExecResults::Attached {
60            output: mut stream, ..
61        } => {
62            while let Some(result) = stream.next().await {
63                match result {
64                    Ok(log_output) => {
65                        output.push_str(&log_output.to_string());
66                    }
67                    Err(e) => {
68                        return Err(DockerError::Container(format!(
69                            "Error reading exec output: {e}"
70                        )));
71                    }
72                }
73            }
74        }
75        StartExecResults::Detached => {
76            return Err(DockerError::Container(
77                "Exec unexpectedly detached".to_string(),
78            ));
79        }
80    }
81
82    Ok(output)
83}
84
85/// Execute a command with stdin input and capture output
86///
87/// Creates an exec instance with stdin attached, writes the provided data to
88/// stdin, then collects stdout/stderr. Used for commands like `chpasswd` that
89/// read passwords from stdin (never from command arguments for security).
90///
91/// # Arguments
92/// * `client` - Docker client
93/// * `container` - Container name or ID
94/// * `cmd` - Command and arguments to execute
95/// * `stdin_data` - Data to write to the command's stdin
96///
97/// # Security Note
98/// This function is specifically designed for secure password handling.
99/// The password is written to stdin and never appears in process arguments
100/// or command logs.
101///
102/// # Example
103/// ```ignore
104/// // Set password via chpasswd (secure, non-interactive)
105/// exec_command_with_stdin(
106///     &client,
107///     "opencode-cloud",
108///     vec!["chpasswd"],
109///     "username:password\n"
110/// ).await?;
111/// ```
112pub async fn exec_command_with_stdin(
113    client: &DockerClient,
114    container: &str,
115    cmd: Vec<&str>,
116    stdin_data: &str,
117) -> Result<String, DockerError> {
118    let exec_config = CreateExecOptions {
119        attach_stdin: Some(true),
120        attach_stdout: Some(true),
121        attach_stderr: Some(true),
122        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
123        user: Some("root".to_string()),
124        ..Default::default()
125    };
126
127    let exec = client
128        .inner()
129        .create_exec(container, exec_config)
130        .await
131        .map_err(|e| DockerError::Container(format!("Failed to create exec: {e}")))?;
132
133    let start_config = StartExecOptions {
134        detach: false,
135        ..Default::default()
136    };
137
138    let mut output = String::new();
139
140    match client
141        .inner()
142        .start_exec(&exec.id, Some(start_config))
143        .await
144        .map_err(|e| DockerError::Container(format!("Failed to start exec: {e}")))?
145    {
146        StartExecResults::Attached {
147            output: mut stream,
148            input: mut input_sink,
149        } => {
150            // Write stdin data using AsyncWrite
151            input_sink
152                .write_all(stdin_data.as_bytes())
153                .await
154                .map_err(|e| DockerError::Container(format!("Failed to write to stdin: {e}")))?;
155
156            // Close stdin to signal EOF
157            input_sink
158                .shutdown()
159                .await
160                .map_err(|e| DockerError::Container(format!("Failed to close stdin: {e}")))?;
161
162            // Collect output
163            while let Some(result) = stream.next().await {
164                match result {
165                    Ok(log_output) => {
166                        output.push_str(&log_output.to_string());
167                    }
168                    Err(e) => {
169                        return Err(DockerError::Container(format!(
170                            "Error reading exec output: {e}"
171                        )));
172                    }
173                }
174            }
175        }
176        StartExecResults::Detached => {
177            return Err(DockerError::Container(
178                "Exec unexpectedly detached".to_string(),
179            ));
180        }
181    }
182
183    Ok(output)
184}
185
186/// Execute a command and return its exit code
187///
188/// Runs a command in the container and returns the exit code instead of output.
189/// Useful for checking if a command succeeded (exit code 0) or failed.
190///
191/// # Arguments
192/// * `client` - Docker client
193/// * `container` - Container name or ID
194/// * `cmd` - Command and arguments to execute
195///
196/// # Example
197/// ```ignore
198/// // Check if user exists (id -u returns 0 if user exists)
199/// let exit_code = exec_command_exit_code(&client, "opencode-cloud", vec!["id", "-u", "admin"]).await?;
200/// let user_exists = exit_code == 0;
201/// ```
202pub async fn exec_command_exit_code(
203    client: &DockerClient,
204    container: &str,
205    cmd: Vec<&str>,
206) -> Result<i64, DockerError> {
207    let exec_config = CreateExecOptions {
208        attach_stdout: Some(true),
209        attach_stderr: Some(true),
210        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
211        user: Some("root".to_string()),
212        ..Default::default()
213    };
214
215    let exec = client
216        .inner()
217        .create_exec(container, exec_config)
218        .await
219        .map_err(|e| DockerError::Container(format!("Failed to create exec: {e}")))?;
220
221    let exec_id = exec.id.clone();
222
223    let start_config = StartExecOptions {
224        detach: false,
225        ..Default::default()
226    };
227
228    // Run the command
229    match client
230        .inner()
231        .start_exec(&exec.id, Some(start_config))
232        .await
233        .map_err(|e| DockerError::Container(format!("Failed to start exec: {e}")))?
234    {
235        StartExecResults::Attached { mut output, .. } => {
236            // Drain the output stream (we don't care about the content)
237            while output.next().await.is_some() {}
238        }
239        StartExecResults::Detached => {
240            return Err(DockerError::Container(
241                "Exec unexpectedly detached".to_string(),
242            ));
243        }
244    }
245
246    // Inspect the exec to get exit code
247    let inspect = client
248        .inner()
249        .inspect_exec(&exec_id)
250        .await
251        .map_err(|e| DockerError::Container(format!("Failed to inspect exec: {e}")))?;
252
253    // Exit code is None if process is still running, which shouldn't happen
254    let exit_code = inspect.exit_code.unwrap_or(-1);
255
256    Ok(exit_code)
257}
258
259#[cfg(test)]
260mod tests {
261    // Note: These tests verify compilation and module structure.
262    // Actual Docker exec tests require a running container and are
263    // covered by integration tests.
264
265    #[test]
266    fn test_command_patterns() {
267        // Verify the command patterns used in user management
268        let useradd_cmd = ["useradd", "-m", "-s", "/bin/bash", "testuser"];
269        assert_eq!(useradd_cmd.len(), 5);
270        assert_eq!(useradd_cmd[0], "useradd");
271
272        let id_cmd = ["id", "-u", "testuser"];
273        assert_eq!(id_cmd.len(), 3);
274
275        let chpasswd_cmd = ["chpasswd"];
276        assert_eq!(chpasswd_cmd.len(), 1);
277    }
278}