@opencode-cloud/core 1.0.7 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,12 +4,18 @@
4
4
  //! errors gracefully and provides clear error messages.
5
5
 
6
6
  use bollard::Docker;
7
+ use std::time::Duration;
7
8
 
8
9
  use super::error::DockerError;
10
+ use crate::host::{HostConfig, SshTunnel};
9
11
 
10
12
  /// Docker client wrapper with connection handling
11
13
  pub struct DockerClient {
12
14
  inner: Docker,
15
+ /// SSH tunnel for remote connections (kept alive for client lifetime)
16
+ _tunnel: Option<SshTunnel>,
17
+ /// Host name for remote connections (None = local)
18
+ host_name: Option<String>,
13
19
  }
14
20
 
15
21
  impl DockerClient {
@@ -21,7 +27,11 @@ impl DockerClient {
21
27
  let docker = Docker::connect_with_local_defaults()
22
28
  .map_err(|e| DockerError::Connection(e.to_string()))?;
23
29
 
24
- Ok(Self { inner: docker })
30
+ Ok(Self {
31
+ inner: docker,
32
+ _tunnel: None,
33
+ host_name: None,
34
+ })
25
35
  }
26
36
 
27
37
  /// Create client with custom timeout (in seconds)
@@ -31,9 +41,109 @@ impl DockerClient {
31
41
  pub fn with_timeout(timeout_secs: u64) -> Result<Self, DockerError> {
32
42
  let docker = Docker::connect_with_local_defaults()
33
43
  .map_err(|e| DockerError::Connection(e.to_string()))?
34
- .with_timeout(std::time::Duration::from_secs(timeout_secs));
44
+ .with_timeout(Duration::from_secs(timeout_secs));
35
45
 
36
- Ok(Self { inner: docker })
46
+ Ok(Self {
47
+ inner: docker,
48
+ _tunnel: None,
49
+ host_name: None,
50
+ })
51
+ }
52
+
53
+ /// Create client connecting to remote Docker daemon via SSH tunnel
54
+ ///
55
+ /// Establishes an SSH tunnel to the remote host and connects Bollard
56
+ /// to the forwarded local port.
57
+ ///
58
+ /// # Arguments
59
+ /// * `host` - Remote host configuration
60
+ /// * `host_name` - Name of the host (for display purposes)
61
+ pub async fn connect_remote(host: &HostConfig, host_name: &str) -> Result<Self, DockerError> {
62
+ // Create SSH tunnel
63
+ let tunnel = SshTunnel::new(host, host_name)
64
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
65
+
66
+ // Wait for tunnel to be ready with exponential backoff
67
+ tunnel
68
+ .wait_ready()
69
+ .await
70
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
71
+
72
+ // Connect Bollard to the tunnel's local port
73
+ let docker_url = tunnel.docker_url();
74
+ tracing::debug!("Connecting to remote Docker via {}", docker_url);
75
+
76
+ // Retry connection with backoff (tunnel may need a moment)
77
+ let max_attempts = 3;
78
+ let mut last_err = None;
79
+
80
+ for attempt in 0..max_attempts {
81
+ if attempt > 0 {
82
+ let delay = Duration::from_millis(100 * 2u64.pow(attempt));
83
+ tracing::debug!("Retry attempt {} after {:?}", attempt + 1, delay);
84
+ tokio::time::sleep(delay).await;
85
+ }
86
+
87
+ match Docker::connect_with_http(&docker_url, 120, bollard::API_DEFAULT_VERSION) {
88
+ Ok(docker) => {
89
+ // Verify connection works
90
+ match docker.ping().await {
91
+ Ok(_) => {
92
+ tracing::info!("Connected to Docker on {} via SSH tunnel", host_name);
93
+ return Ok(Self {
94
+ inner: docker,
95
+ _tunnel: Some(tunnel),
96
+ host_name: Some(host_name.to_string()),
97
+ });
98
+ }
99
+ Err(e) => {
100
+ tracing::debug!("Ping failed: {}", e);
101
+ last_err = Some(e.to_string());
102
+ }
103
+ }
104
+ }
105
+ Err(e) => {
106
+ tracing::debug!("Connection failed: {}", e);
107
+ last_err = Some(e.to_string());
108
+ }
109
+ }
110
+ }
111
+
112
+ Err(DockerError::Connection(format!(
113
+ "Failed to connect to Docker on {}: {}",
114
+ host_name,
115
+ last_err.unwrap_or_else(|| "unknown error".to_string())
116
+ )))
117
+ }
118
+
119
+ /// Create remote client with custom timeout
120
+ pub async fn connect_remote_with_timeout(
121
+ host: &HostConfig,
122
+ host_name: &str,
123
+ timeout_secs: u64,
124
+ ) -> Result<Self, DockerError> {
125
+ let tunnel = SshTunnel::new(host, host_name)
126
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
127
+
128
+ tunnel
129
+ .wait_ready()
130
+ .await
131
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
132
+
133
+ let docker_url = tunnel.docker_url();
134
+
135
+ let docker =
136
+ Docker::connect_with_http(&docker_url, timeout_secs, bollard::API_DEFAULT_VERSION)
137
+ .map_err(|e| DockerError::Connection(e.to_string()))?;
138
+
139
+ // Verify connection
140
+ docker.ping().await.map_err(DockerError::from)?;
141
+
142
+ Ok(Self {
143
+ inner: docker,
144
+ _tunnel: Some(tunnel),
145
+ host_name: Some(host_name.to_string()),
146
+ })
37
147
  }
38
148
 
39
149
  /// Verify connection to Docker daemon
@@ -57,6 +167,16 @@ impl DockerClient {
57
167
  Ok(version_str)
58
168
  }
59
169
 
170
+ /// Get the host name if this is a remote connection
171
+ pub fn host_name(&self) -> Option<&str> {
172
+ self.host_name.as_deref()
173
+ }
174
+
175
+ /// Check if this is a remote connection
176
+ pub fn is_remote(&self) -> bool {
177
+ self._tunnel.is_some()
178
+ }
179
+
60
180
  /// Access inner Bollard client for advanced operations
61
181
  pub fn inner(&self) -> &Docker {
62
182
  &self.inner
@@ -81,4 +201,13 @@ mod tests {
81
201
  let result = DockerClient::with_timeout(600);
82
202
  drop(result);
83
203
  }
204
+
205
+ #[test]
206
+ fn test_host_name_methods() {
207
+ // Local client has no host name
208
+ if let Ok(client) = DockerClient::new() {
209
+ assert!(client.host_name().is_none());
210
+ assert!(!client.is_remote());
211
+ }
212
+ }
84
213
  }
@@ -33,28 +33,36 @@ pub const OPENCODE_WEB_PORT: u16 = 3000;
33
33
  /// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
34
34
  /// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
35
35
  /// * `env_vars` - Additional environment variables (optional)
36
+ /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
37
+ /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
38
+ /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
39
+ #[allow(clippy::too_many_arguments)]
36
40
  pub async fn create_container(
37
41
  client: &DockerClient,
38
42
  name: Option<&str>,
39
43
  image: Option<&str>,
40
44
  opencode_web_port: Option<u16>,
41
45
  env_vars: Option<Vec<String>>,
46
+ bind_address: Option<&str>,
47
+ cockpit_port: Option<u16>,
48
+ cockpit_enabled: Option<bool>,
42
49
  ) -> Result<String, DockerError> {
43
50
  let container_name = name.unwrap_or(CONTAINER_NAME);
44
51
  let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
45
52
  let image_name = image.unwrap_or(&default_image);
46
53
  let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
54
+ let cockpit_port_val = cockpit_port.unwrap_or(9090);
55
+ let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
47
56
 
48
57
  debug!(
49
- "Creating container {} from image {} with port {}",
50
- container_name, image_name, port
58
+ "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
59
+ container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
51
60
  );
52
61
 
53
62
  // Check if container already exists
54
63
  if container_exists(client, container_name).await? {
55
64
  return Err(DockerError::Container(format!(
56
- "Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name.",
57
- container_name
65
+ "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
58
66
  )));
59
67
  }
60
68
 
@@ -68,8 +76,7 @@ pub async fn create_container(
68
76
 
69
77
  if !super::image::image_exists(client, image_repo, image_tag).await? {
70
78
  return Err(DockerError::Container(format!(
71
- "Image '{}' not found. Run 'occ pull' first to download the image.",
72
- image_name
79
+ "Image '{image_name}' not found. Run 'occ pull' first to download the image."
73
80
  )));
74
81
  }
75
82
 
@@ -98,26 +105,85 @@ pub async fn create_container(
98
105
  },
99
106
  ];
100
107
 
101
- // Create port bindings (localhost only for security)
108
+ // Create port bindings (default to localhost for security)
109
+ let bind_addr = bind_address.unwrap_or("127.0.0.1");
102
110
  let mut port_bindings: PortMap = HashMap::new();
111
+
112
+ // opencode web port
103
113
  port_bindings.insert(
104
114
  "3000/tcp".to_string(),
105
115
  Some(vec![PortBinding {
106
- host_ip: Some("127.0.0.1".to_string()),
116
+ host_ip: Some(bind_addr.to_string()),
107
117
  host_port: Some(port.to_string()),
108
118
  }]),
109
119
  );
110
120
 
121
+ // Cockpit port (if enabled)
122
+ // Container always listens on 9090, map to host's configured port
123
+ if cockpit_enabled_val {
124
+ port_bindings.insert(
125
+ "9090/tcp".to_string(),
126
+ Some(vec![PortBinding {
127
+ host_ip: Some(bind_addr.to_string()),
128
+ host_port: Some(cockpit_port_val.to_string()),
129
+ }]),
130
+ );
131
+ }
132
+
111
133
  // Create exposed ports map
112
134
  let mut exposed_ports = HashMap::new();
113
135
  exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
136
+ if cockpit_enabled_val {
137
+ exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
138
+ }
114
139
 
115
140
  // Create host config
116
- let host_config = HostConfig {
117
- mounts: Some(mounts),
118
- port_bindings: Some(port_bindings),
119
- auto_remove: Some(false),
120
- ..Default::default()
141
+ // When Cockpit is enabled, add systemd-specific settings (requires Linux host)
142
+ // When Cockpit is disabled, use simpler tini-based config (works everywhere)
143
+ let host_config = if cockpit_enabled_val {
144
+ HostConfig {
145
+ mounts: Some(mounts),
146
+ port_bindings: Some(port_bindings),
147
+ auto_remove: Some(false),
148
+ // CAP_SYS_ADMIN required for systemd cgroup access
149
+ cap_add: Some(vec!["SYS_ADMIN".to_string()]),
150
+ // tmpfs for /run, /run/lock, and /tmp (required for systemd)
151
+ tmpfs: Some(HashMap::from([
152
+ ("/run".to_string(), "exec".to_string()),
153
+ ("/run/lock".to_string(), String::new()),
154
+ ("/tmp".to_string(), String::new()),
155
+ ])),
156
+ // cgroup mount (read-write for systemd)
157
+ binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
158
+ // Use HOST cgroup namespace for systemd compatibility across Linux distros:
159
+ // - cgroups v2 (Amazon Linux 2023, Fedora 31+, Ubuntu 21.10+, Debian 11+): required
160
+ // - cgroups v1 (CentOS 7, Ubuntu 18.04, Debian 10): works fine
161
+ // - Docker Desktop (macOS/Windows VM): works fine
162
+ // Note: PRIVATE mode is more isolated but causes systemd to exit(255) on cgroups v2.
163
+ // Since we already use privileged mode, HOST namespace is acceptable.
164
+ cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
165
+ // Privileged mode required for systemd to manage cgroups and system services
166
+ privileged: Some(true),
167
+ ..Default::default()
168
+ }
169
+ } else {
170
+ // Simple config for tini mode (works on macOS and Linux)
171
+ HostConfig {
172
+ mounts: Some(mounts),
173
+ port_bindings: Some(port_bindings),
174
+ auto_remove: Some(false),
175
+ ..Default::default()
176
+ }
177
+ };
178
+
179
+ // Build environment variables
180
+ // Add USE_SYSTEMD=1 when Cockpit is enabled to tell entrypoint to use systemd
181
+ let final_env = if cockpit_enabled_val {
182
+ let mut env = env_vars.unwrap_or_default();
183
+ env.push("USE_SYSTEMD=1".to_string());
184
+ Some(env)
185
+ } else {
186
+ env_vars
121
187
  };
122
188
 
123
189
  // Create container config
@@ -126,7 +192,7 @@ pub async fn create_container(
126
192
  hostname: Some(CONTAINER_NAME.to_string()),
127
193
  working_dir: Some("/workspace".to_string()),
128
194
  exposed_ports: Some(exposed_ports),
129
- env: env_vars,
195
+ env: final_env,
130
196
  host_config: Some(host_config),
131
197
  ..Default::default()
132
198
  };
@@ -145,11 +211,10 @@ pub async fn create_container(
145
211
  let msg = e.to_string();
146
212
  if msg.contains("port is already allocated") || msg.contains("address already in use") {
147
213
  DockerError::Container(format!(
148
- "Port {} is already in use. Stop the service using that port or use a different port with --port.",
149
- port
214
+ "Port {port} is already in use. Stop the service using that port or use a different port with --port."
150
215
  ))
151
216
  } else {
152
- DockerError::Container(format!("Failed to create container: {}", e))
217
+ DockerError::Container(format!("Failed to create container: {e}"))
153
218
  }
154
219
  })?;
155
220
 
@@ -165,9 +230,7 @@ pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), Do
165
230
  .inner()
166
231
  .start_container(name, None::<StartContainerOptions<String>>)
167
232
  .await
168
- .map_err(|e| {
169
- DockerError::Container(format!("Failed to start container {}: {}", name, e))
170
- })?;
233
+ .map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
171
234
 
172
235
  debug!("Container {} started", name);
173
236
  Ok(())
@@ -198,9 +261,9 @@ pub async fn stop_container(
198
261
  // "container already stopped" is not an error
199
262
  if msg.contains("is not running") || msg.contains("304") {
200
263
  debug!("Container {} was already stopped", name);
201
- return DockerError::Container(format!("Container '{}' is not running", name));
264
+ return DockerError::Container(format!("Container '{name}' is not running"));
202
265
  }
203
- DockerError::Container(format!("Failed to stop container {}: {}", name, e))
266
+ DockerError::Container(format!("Failed to stop container {name}: {e}"))
204
267
  })?;
205
268
 
206
269
  debug!("Container {} stopped", name);
@@ -230,9 +293,7 @@ pub async fn remove_container(
230
293
  .inner()
231
294
  .remove_container(name, Some(options))
232
295
  .await
233
- .map_err(|e| {
234
- DockerError::Container(format!("Failed to remove container {}: {}", name, e))
235
- })?;
296
+ .map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
236
297
 
237
298
  debug!("Container {} removed", name);
238
299
  Ok(())
@@ -248,8 +309,7 @@ pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool,
248
309
  status_code: 404, ..
249
310
  }) => Ok(false),
250
311
  Err(e) => Err(DockerError::Container(format!(
251
- "Failed to inspect container {}: {}",
252
- name, e
312
+ "Failed to inspect container {name}: {e}"
253
313
  ))),
254
314
  }
255
315
  }
@@ -267,8 +327,7 @@ pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<b
267
327
  status_code: 404, ..
268
328
  }) => Ok(false),
269
329
  Err(e) => Err(DockerError::Container(format!(
270
- "Failed to inspect container {}: {}",
271
- name, e
330
+ "Failed to inspect container {name}: {e}"
272
331
  ))),
273
332
  }
274
333
  }
@@ -289,12 +348,10 @@ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String
289
348
  Err(bollard::errors::Error::DockerResponseServerError {
290
349
  status_code: 404, ..
291
350
  }) => Err(DockerError::Container(format!(
292
- "Container '{}' not found",
293
- name
351
+ "Container '{name}' not found"
294
352
  ))),
295
353
  Err(e) => Err(DockerError::Container(format!(
296
- "Failed to inspect container {}: {}",
297
- name, e
354
+ "Failed to inspect container {name}: {e}"
298
355
  ))),
299
356
  }
300
357
  }
@@ -0,0 +1,278 @@
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
+
7
+ use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
8
+ use futures_util::StreamExt;
9
+ use tokio::io::AsyncWriteExt;
10
+
11
+ use 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
+ /// ```
27
+ pub 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
+ /// ```
112
+ pub 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
+ /// ```
202
+ pub 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)]
260
+ mod 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
+ }