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