@opencode-cloud/core 1.0.8 → 3.0.15

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.
@@ -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
+ }
@@ -0,0 +1,165 @@
1
+ //! Health check module for OpenCode service
2
+ //!
3
+ //! Provides health checking functionality by querying OpenCode's /global/health endpoint.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+ use std::time::Duration;
7
+ use thiserror::Error;
8
+
9
+ use super::DockerClient;
10
+
11
+ /// Response from OpenCode's /global/health endpoint
12
+ #[derive(Debug, Clone, Serialize, Deserialize)]
13
+ pub struct HealthResponse {
14
+ /// Whether the service is healthy
15
+ pub healthy: bool,
16
+ /// Service version string
17
+ pub version: String,
18
+ }
19
+
20
+ /// Extended health response including container stats
21
+ #[derive(Debug, Serialize)]
22
+ pub struct ExtendedHealthResponse {
23
+ /// Whether the service is healthy
24
+ pub healthy: bool,
25
+ /// Service version string
26
+ pub version: String,
27
+ /// Container state (running, stopped, etc.)
28
+ pub container_state: String,
29
+ /// Uptime in seconds
30
+ pub uptime_seconds: u64,
31
+ /// Memory usage in megabytes (if available)
32
+ #[serde(skip_serializing_if = "Option::is_none")]
33
+ pub memory_usage_mb: Option<u64>,
34
+ }
35
+
36
+ /// Errors that can occur during health checks
37
+ #[derive(Debug, Error)]
38
+ pub enum HealthError {
39
+ /// HTTP request failed
40
+ #[error("Request failed: {0}")]
41
+ RequestError(#[from] reqwest::Error),
42
+
43
+ /// Service returned non-200 status
44
+ #[error("Service unhealthy (HTTP {0})")]
45
+ Unhealthy(u16),
46
+
47
+ /// Connection refused - service may not be running
48
+ #[error("Connection refused - service may not be running")]
49
+ ConnectionRefused,
50
+
51
+ /// Request timed out - service may be starting
52
+ #[error("Timeout - service may be starting")]
53
+ Timeout,
54
+ }
55
+
56
+ /// Check health by querying OpenCode's /global/health endpoint
57
+ ///
58
+ /// Returns the health response on success (HTTP 200).
59
+ /// Returns an error for connection issues, timeouts, or non-200 responses.
60
+ pub async fn check_health(port: u16) -> Result<HealthResponse, HealthError> {
61
+ let url = format!("http://127.0.0.1:{port}/global/health");
62
+
63
+ let client = reqwest::Client::builder()
64
+ .timeout(Duration::from_secs(5))
65
+ .build()?;
66
+
67
+ let response = match client.get(&url).send().await {
68
+ Ok(resp) => resp,
69
+ Err(e) => {
70
+ // Check for connection refused
71
+ if e.is_connect() {
72
+ return Err(HealthError::ConnectionRefused);
73
+ }
74
+ // Check for timeout
75
+ if e.is_timeout() {
76
+ return Err(HealthError::Timeout);
77
+ }
78
+ return Err(HealthError::RequestError(e));
79
+ }
80
+ };
81
+
82
+ let status = response.status();
83
+
84
+ if status.is_success() {
85
+ let health_response = response.json::<HealthResponse>().await?;
86
+ Ok(health_response)
87
+ } else {
88
+ Err(HealthError::Unhealthy(status.as_u16()))
89
+ }
90
+ }
91
+
92
+ /// Check health with extended information including container stats
93
+ ///
94
+ /// Combines basic health check with container statistics from Docker.
95
+ /// If container stats fail, still returns response with container_state = "unknown".
96
+ pub async fn check_health_extended(
97
+ client: &DockerClient,
98
+ port: u16,
99
+ ) -> Result<ExtendedHealthResponse, HealthError> {
100
+ // Get basic health info
101
+ let health = check_health(port).await?;
102
+
103
+ // Get container stats
104
+ let container_name = super::CONTAINER_NAME;
105
+
106
+ // Try to get container info
107
+ let (container_state, uptime_seconds, memory_usage_mb) =
108
+ match client.inner().inspect_container(container_name, None).await {
109
+ Ok(info) => {
110
+ let state = info
111
+ .state
112
+ .as_ref()
113
+ .and_then(|s| s.status.as_ref())
114
+ .map(|s| s.to_string())
115
+ .unwrap_or_else(|| "unknown".to_string());
116
+
117
+ // Calculate uptime
118
+ let uptime = info
119
+ .state
120
+ .as_ref()
121
+ .and_then(|s| s.started_at.as_ref())
122
+ .and_then(|started| {
123
+ let timestamp = chrono::DateTime::parse_from_rfc3339(started).ok()?;
124
+ let now = chrono::Utc::now();
125
+ let started_utc = timestamp.with_timezone(&chrono::Utc);
126
+ if now >= started_utc {
127
+ Some((now - started_utc).num_seconds() as u64)
128
+ } else {
129
+ None
130
+ }
131
+ })
132
+ .unwrap_or(0);
133
+
134
+ // Get memory usage (would require stats API call - skip for now)
135
+ let memory = None;
136
+
137
+ (state, uptime, memory)
138
+ }
139
+ Err(_) => ("unknown".to_string(), 0, None),
140
+ };
141
+
142
+ Ok(ExtendedHealthResponse {
143
+ healthy: health.healthy,
144
+ version: health.version,
145
+ container_state,
146
+ uptime_seconds,
147
+ memory_usage_mb,
148
+ })
149
+ }
150
+
151
+ #[cfg(test)]
152
+ mod tests {
153
+ use super::*;
154
+
155
+ #[tokio::test]
156
+ async fn test_health_check_connection_refused() {
157
+ // Port 1 should always refuse connection
158
+ let result = check_health(1).await;
159
+ assert!(result.is_err());
160
+ match result.unwrap_err() {
161
+ HealthError::ConnectionRefused => {}
162
+ other => panic!("Expected ConnectionRefused, got: {other:?}"),
163
+ }
164
+ }
165
+ }
@@ -203,8 +203,7 @@ pub async fn pull_image(
203
203
  Ok(full_name)
204
204
  }
205
205
  Err(dockerhub_err) => Err(DockerError::Pull(format!(
206
- "Failed to pull from both registries. GHCR: {}. Docker Hub: {}",
207
- ghcr_err, dockerhub_err
206
+ "Failed to pull from both registries. GHCR: {ghcr_err}. Docker Hub: {dockerhub_err}"
208
207
  ))),
209
208
  }
210
209
  }
@@ -246,8 +245,7 @@ async fn pull_from_registry(
246
245
 
247
246
  Err(last_error.unwrap_or_else(|| {
248
247
  DockerError::Pull(format!(
249
- "Pull failed for {} after {} attempts",
250
- full_name, MAX_PULL_RETRIES
248
+ "Pull failed for {full_name} after {MAX_PULL_RETRIES} attempts"
251
249
  ))
252
250
  }))
253
251
  }
package/src/docker/mod.rs CHANGED
@@ -8,13 +8,23 @@
8
8
  //! - Image build and pull operations
9
9
  //! - Volume management for persistent storage
10
10
  //! - Container lifecycle (create, start, stop, remove)
11
+ //! - Container exec for running commands inside containers
12
+ //! - User management operations (create, delete, lock/unlock users)
13
+ //! - Image update and rollback operations
11
14
 
12
15
  mod client;
13
16
  pub mod container;
14
17
  mod dockerfile;
15
18
  mod error;
19
+ pub mod exec;
20
+ mod health;
16
21
  pub mod image;
22
+ pub mod mount;
17
23
  pub mod progress;
24
+ pub mod state;
25
+ pub mod update;
26
+ pub mod users;
27
+ mod version;
18
28
  pub mod volume;
19
29
 
20
30
  // Core types
@@ -22,24 +32,51 @@ pub use client::DockerClient;
22
32
  pub use error::DockerError;
23
33
  pub use progress::ProgressReporter;
24
34
 
35
+ // Health check operations
36
+ pub use health::{
37
+ ExtendedHealthResponse, HealthError, HealthResponse, check_health, check_health_extended,
38
+ };
39
+
25
40
  // Dockerfile constants
26
41
  pub use dockerfile::{DOCKERFILE, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
27
42
 
28
43
  // Image operations
29
44
  pub use image::{build_image, image_exists, pull_image};
30
45
 
46
+ // Update operations
47
+ pub use update::{UpdateResult, has_previous_image, rollback_image, update_image};
48
+
49
+ // Version detection
50
+ pub use version::{VERSION_LABEL, get_cli_version, get_image_version, versions_compatible};
51
+
52
+ // Container exec operations
53
+ pub use exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
54
+
55
+ // User management operations
56
+ pub use users::{
57
+ UserInfo, create_user, delete_user, list_users, lock_user, set_user_password, unlock_user,
58
+ user_exists,
59
+ };
60
+
31
61
  // Volume management
32
62
  pub use volume::{
33
63
  MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS,
34
64
  VOLUME_SESSION, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
35
65
  };
36
66
 
67
+ // Bind mount parsing and validation
68
+ pub use mount::{MountError, ParsedMount, check_container_path_warning, validate_mount_path};
69
+
37
70
  // Container lifecycle
38
71
  pub use container::{
39
- CONTAINER_NAME, OPENCODE_WEB_PORT, container_exists, container_is_running, container_state,
40
- create_container, remove_container, start_container, stop_container,
72
+ CONTAINER_NAME, ContainerBindMount, ContainerPorts, OPENCODE_WEB_PORT, container_exists,
73
+ container_is_running, container_state, create_container, get_container_bind_mounts,
74
+ get_container_ports, remove_container, start_container, stop_container,
41
75
  };
42
76
 
77
+ // Image state tracking
78
+ pub use state::{ImageState, clear_state, get_state_path, load_state, save_state};
79
+
43
80
  /// Full setup: ensure volumes exist, create container if needed, start it
44
81
  ///
45
82
  /// This is the primary entry point for starting the opencode service.
@@ -49,10 +86,18 @@ pub use container::{
49
86
  /// * `client` - Docker client
50
87
  /// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
51
88
  /// * `env_vars` - Additional environment variables (optional)
89
+ /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
90
+ /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
91
+ /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
92
+ /// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
52
93
  pub async fn setup_and_start(
53
94
  client: &DockerClient,
54
95
  opencode_web_port: Option<u16>,
55
96
  env_vars: Option<Vec<String>>,
97
+ bind_address: Option<&str>,
98
+ cockpit_port: Option<u16>,
99
+ cockpit_enabled: Option<bool>,
100
+ bind_mounts: Option<Vec<mount::ParsedMount>>,
56
101
  ) -> Result<String, DockerError> {
57
102
  // Ensure volumes exist first
58
103
  volume::ensure_volumes_exist(client).await?;
@@ -65,13 +110,24 @@ pub async fn setup_and_start(
65
110
  .inspect_container(container::CONTAINER_NAME, None)
66
111
  .await
67
112
  .map_err(|e| {
68
- DockerError::Container(format!("Failed to inspect existing container: {}", e))
113
+ DockerError::Container(format!("Failed to inspect existing container: {e}"))
69
114
  })?;
70
115
  info.id
71
116
  .unwrap_or_else(|| container::CONTAINER_NAME.to_string())
72
117
  } else {
73
118
  // Create new container
74
- container::create_container(client, None, None, opencode_web_port, env_vars).await?
119
+ container::create_container(
120
+ client,
121
+ None,
122
+ None,
123
+ opencode_web_port,
124
+ env_vars,
125
+ bind_address,
126
+ cockpit_port,
127
+ cockpit_enabled,
128
+ bind_mounts,
129
+ )
130
+ .await?
75
131
  };
76
132
 
77
133
  // Start if not running
@@ -82,25 +138,33 @@ pub async fn setup_and_start(
82
138
  Ok(container_id)
83
139
  }
84
140
 
141
+ /// Default graceful shutdown timeout in seconds
142
+ pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
143
+
85
144
  /// Stop and optionally remove the opencode container
86
145
  ///
87
146
  /// # Arguments
88
147
  /// * `client` - Docker client
89
148
  /// * `remove` - Also remove the container after stopping
90
- pub async fn stop_service(client: &DockerClient, remove: bool) -> Result<(), DockerError> {
149
+ /// * `timeout_secs` - Graceful shutdown timeout (default: 30 seconds)
150
+ pub async fn stop_service(
151
+ client: &DockerClient,
152
+ remove: bool,
153
+ timeout_secs: Option<i64>,
154
+ ) -> Result<(), DockerError> {
91
155
  let name = container::CONTAINER_NAME;
156
+ let timeout = timeout_secs.unwrap_or(DEFAULT_STOP_TIMEOUT_SECS);
92
157
 
93
158
  // Check if container exists
94
159
  if !container::container_exists(client, name).await? {
95
160
  return Err(DockerError::Container(format!(
96
- "Container '{}' does not exist",
97
- name
161
+ "Container '{name}' does not exist"
98
162
  )));
99
163
  }
100
164
 
101
165
  // Stop if running
102
166
  if container::container_is_running(client, name).await? {
103
- container::stop_container(client, name, None).await?;
167
+ container::stop_container(client, name, Some(timeout)).await?;
104
168
  }
105
169
 
106
170
  // Remove if requested