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-sandbox", 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 and capture output plus exit code
86///
87/// Returns a tuple of (output, exit_code). Exit code is -1 if not available.
88pub async fn exec_command_with_status(
89    client: &DockerClient,
90    container: &str,
91    cmd: Vec<&str>,
92) -> Result<(String, i64), DockerError> {
93    let exec_config = CreateExecOptions {
94        attach_stdout: Some(true),
95        attach_stderr: Some(true),
96        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
97        user: Some("root".to_string()),
98        ..Default::default()
99    };
100
101    let exec = client
102        .inner()
103        .create_exec(container, exec_config)
104        .await
105        .map_err(|e| DockerError::Container(format!("Failed to create exec: {e}")))?;
106
107    let exec_id = exec.id.clone();
108    let start_config = StartExecOptions {
109        detach: false,
110        ..Default::default()
111    };
112
113    let mut output = String::new();
114
115    match client
116        .inner()
117        .start_exec(&exec.id, Some(start_config))
118        .await
119        .map_err(|e| DockerError::Container(format!("Failed to start exec: {e}")))?
120    {
121        StartExecResults::Attached {
122            output: mut stream, ..
123        } => {
124            while let Some(result) = stream.next().await {
125                match result {
126                    Ok(log_output) => {
127                        output.push_str(&log_output.to_string());
128                    }
129                    Err(e) => {
130                        return Err(DockerError::Container(format!(
131                            "Error reading exec output: {e}"
132                        )));
133                    }
134                }
135            }
136        }
137        StartExecResults::Detached => {
138            return Err(DockerError::Container(
139                "Exec unexpectedly detached".to_string(),
140            ));
141        }
142    }
143
144    let inspect = client
145        .inner()
146        .inspect_exec(&exec_id)
147        .await
148        .map_err(|e| DockerError::Container(format!("Failed to inspect exec: {e}")))?;
149
150    let exit_code = inspect.exit_code.unwrap_or(-1);
151
152    Ok((output, exit_code))
153}
154
155/// Execute a command with stdin input and capture output
156///
157/// Creates an exec instance with stdin attached, writes the provided data to
158/// stdin, then collects stdout/stderr. Used for commands like `chpasswd` that
159/// read passwords from stdin (never from command arguments for security).
160///
161/// # Arguments
162/// * `client` - Docker client
163/// * `container` - Container name or ID
164/// * `cmd` - Command and arguments to execute
165/// * `stdin_data` - Data to write to the command's stdin
166///
167/// # Security Note
168/// This function is specifically designed for secure password handling.
169/// The password is written to stdin and never appears in process arguments
170/// or command logs.
171///
172/// # Example
173/// ```ignore
174/// // Set password via chpasswd (secure, non-interactive)
175/// exec_command_with_stdin(
176///     &client,
177///     "opencode-cloud-sandbox",
178///     vec!["chpasswd"],
179///     "username:password\n"
180/// ).await?;
181/// ```
182pub async fn exec_command_with_stdin(
183    client: &DockerClient,
184    container: &str,
185    cmd: Vec<&str>,
186    stdin_data: &str,
187) -> Result<String, DockerError> {
188    let exec_config = CreateExecOptions {
189        attach_stdin: Some(true),
190        attach_stdout: Some(true),
191        attach_stderr: Some(true),
192        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
193        user: Some("root".to_string()),
194        ..Default::default()
195    };
196
197    let exec = client
198        .inner()
199        .create_exec(container, exec_config)
200        .await
201        .map_err(|e| DockerError::Container(format!("Failed to create exec: {e}")))?;
202
203    let start_config = StartExecOptions {
204        detach: false,
205        ..Default::default()
206    };
207
208    let mut output = String::new();
209
210    match client
211        .inner()
212        .start_exec(&exec.id, Some(start_config))
213        .await
214        .map_err(|e| DockerError::Container(format!("Failed to start exec: {e}")))?
215    {
216        StartExecResults::Attached {
217            output: mut stream,
218            input: mut input_sink,
219        } => {
220            // Write stdin data using AsyncWrite
221            input_sink
222                .write_all(stdin_data.as_bytes())
223                .await
224                .map_err(|e| DockerError::Container(format!("Failed to write to stdin: {e}")))?;
225
226            // Close stdin to signal EOF
227            input_sink
228                .shutdown()
229                .await
230                .map_err(|e| DockerError::Container(format!("Failed to close stdin: {e}")))?;
231
232            // Collect output
233            while let Some(result) = stream.next().await {
234                match result {
235                    Ok(log_output) => {
236                        output.push_str(&log_output.to_string());
237                    }
238                    Err(e) => {
239                        return Err(DockerError::Container(format!(
240                            "Error reading exec output: {e}"
241                        )));
242                    }
243                }
244            }
245        }
246        StartExecResults::Detached => {
247            return Err(DockerError::Container(
248                "Exec unexpectedly detached".to_string(),
249            ));
250        }
251    }
252
253    Ok(output)
254}
255
256/// Execute a command and return its exit code
257///
258/// Runs a command in the container and returns the exit code instead of output.
259/// Useful for checking if a command succeeded (exit code 0) or failed.
260///
261/// # Arguments
262/// * `client` - Docker client
263/// * `container` - Container name or ID
264/// * `cmd` - Command and arguments to execute
265///
266/// # Example
267/// ```ignore
268/// // Check if user exists (id -u returns 0 if user exists)
269/// let exit_code = exec_command_exit_code(&client, "opencode-cloud", vec!["id", "-u", "admin"]).await?;
270/// let user_exists = exit_code == 0;
271/// ```
272pub async fn exec_command_exit_code(
273    client: &DockerClient,
274    container: &str,
275    cmd: Vec<&str>,
276) -> Result<i64, DockerError> {
277    let exec_config = CreateExecOptions {
278        attach_stdout: Some(true),
279        attach_stderr: Some(true),
280        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
281        user: Some("root".to_string()),
282        ..Default::default()
283    };
284
285    let exec = client
286        .inner()
287        .create_exec(container, exec_config)
288        .await
289        .map_err(|e| DockerError::Container(format!("Failed to create exec: {e}")))?;
290
291    let exec_id = exec.id.clone();
292
293    let start_config = StartExecOptions {
294        detach: false,
295        ..Default::default()
296    };
297
298    // Run the command
299    match client
300        .inner()
301        .start_exec(&exec.id, Some(start_config))
302        .await
303        .map_err(|e| DockerError::Container(format!("Failed to start exec: {e}")))?
304    {
305        StartExecResults::Attached { mut output, .. } => {
306            // Drain the output stream (we don't care about the content)
307            while output.next().await.is_some() {}
308        }
309        StartExecResults::Detached => {
310            return Err(DockerError::Container(
311                "Exec unexpectedly detached".to_string(),
312            ));
313        }
314    }
315
316    // Inspect the exec to get exit code
317    let inspect = client
318        .inner()
319        .inspect_exec(&exec_id)
320        .await
321        .map_err(|e| DockerError::Container(format!("Failed to inspect exec: {e}")))?;
322
323    // Exit code is None if process is still running, which shouldn't happen
324    let exit_code = inspect.exit_code.unwrap_or(-1);
325
326    Ok(exit_code)
327}
328
329#[cfg(test)]
330mod tests {
331    // Note: These tests verify compilation and module structure.
332    // Actual Docker exec tests require a running container and are
333    // covered by integration tests.
334
335    #[test]
336    fn test_command_patterns() {
337        // Verify the command patterns used in user management
338        let useradd_cmd = ["useradd", "-m", "-s", "/bin/bash", "testuser"];
339        assert_eq!(useradd_cmd.len(), 5);
340        assert_eq!(useradd_cmd[0], "useradd");
341
342        let id_cmd = ["id", "-u", "testuser"];
343        assert_eq!(id_cmd.len(), 3);
344
345        let chpasswd_cmd = ["chpasswd"];
346        assert_eq!(chpasswd_cmd.len(), 1);
347    }
348}