@opencode-cloud/core 1.0.8 → 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.
@@ -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,21 @@
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;
17
22
  pub mod progress;
23
+ pub mod update;
24
+ pub mod users;
25
+ mod version;
18
26
  pub mod volume;
19
27
 
20
28
  // Core types
@@ -22,12 +30,32 @@ pub use client::DockerClient;
22
30
  pub use error::DockerError;
23
31
  pub use progress::ProgressReporter;
24
32
 
33
+ // Health check operations
34
+ pub use health::{
35
+ ExtendedHealthResponse, HealthError, HealthResponse, check_health, check_health_extended,
36
+ };
37
+
25
38
  // Dockerfile constants
26
39
  pub use dockerfile::{DOCKERFILE, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
27
40
 
28
41
  // Image operations
29
42
  pub use image::{build_image, image_exists, pull_image};
30
43
 
44
+ // Update operations
45
+ pub use update::{UpdateResult, has_previous_image, rollback_image, update_image};
46
+
47
+ // Version detection
48
+ pub use version::{VERSION_LABEL, get_cli_version, get_image_version, versions_compatible};
49
+
50
+ // Container exec operations
51
+ pub use exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
52
+
53
+ // User management operations
54
+ pub use users::{
55
+ UserInfo, create_user, delete_user, list_users, lock_user, set_user_password, unlock_user,
56
+ user_exists,
57
+ };
58
+
31
59
  // Volume management
32
60
  pub use volume::{
33
61
  MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS,
@@ -49,10 +77,16 @@ pub use container::{
49
77
  /// * `client` - Docker client
50
78
  /// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
51
79
  /// * `env_vars` - Additional environment variables (optional)
80
+ /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
81
+ /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
82
+ /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
52
83
  pub async fn setup_and_start(
53
84
  client: &DockerClient,
54
85
  opencode_web_port: Option<u16>,
55
86
  env_vars: Option<Vec<String>>,
87
+ bind_address: Option<&str>,
88
+ cockpit_port: Option<u16>,
89
+ cockpit_enabled: Option<bool>,
56
90
  ) -> Result<String, DockerError> {
57
91
  // Ensure volumes exist first
58
92
  volume::ensure_volumes_exist(client).await?;
@@ -65,13 +99,23 @@ pub async fn setup_and_start(
65
99
  .inspect_container(container::CONTAINER_NAME, None)
66
100
  .await
67
101
  .map_err(|e| {
68
- DockerError::Container(format!("Failed to inspect existing container: {}", e))
102
+ DockerError::Container(format!("Failed to inspect existing container: {e}"))
69
103
  })?;
70
104
  info.id
71
105
  .unwrap_or_else(|| container::CONTAINER_NAME.to_string())
72
106
  } else {
73
107
  // Create new container
74
- container::create_container(client, None, None, opencode_web_port, env_vars).await?
108
+ container::create_container(
109
+ client,
110
+ None,
111
+ None,
112
+ opencode_web_port,
113
+ env_vars,
114
+ bind_address,
115
+ cockpit_port,
116
+ cockpit_enabled,
117
+ )
118
+ .await?
75
119
  };
76
120
 
77
121
  // Start if not running
@@ -93,8 +137,7 @@ pub async fn stop_service(client: &DockerClient, remove: bool) -> Result<(), Doc
93
137
  // Check if container exists
94
138
  if !container::container_exists(client, name).await? {
95
139
  return Err(DockerError::Container(format!(
96
- "Container '{}' does not exist",
97
- name
140
+ "Container '{name}' does not exist"
98
141
  )));
99
142
  }
100
143
 
@@ -48,9 +48,9 @@ fn format_elapsed(duration: Duration) -> String {
48
48
  let seconds = total_secs % 60;
49
49
 
50
50
  if hours > 0 {
51
- format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
51
+ format!("{hours:02}:{minutes:02}:{seconds:02}")
52
52
  } else {
53
- format!("{:02}:{:02}", minutes, seconds)
53
+ format!("{minutes:02}:{seconds:02}")
54
54
  }
55
55
  }
56
56
 
@@ -116,8 +116,8 @@ impl ProgressReporter {
116
116
  // Format: "[elapsed] Context · message" or "[elapsed] message"
117
117
  // Timer at the beginning for easy scanning
118
118
  match &self.context {
119
- Some(ctx) => format!("[{}] {} · {}", elapsed, ctx, clean_msg),
120
- None => format!("[{}] {}", elapsed, clean_msg),
119
+ Some(ctx) => format!("[{elapsed}] {ctx} · {clean_msg}"),
120
+ None => format!("[{elapsed}] {clean_msg}"),
121
121
  }
122
122
  }
123
123
 
@@ -0,0 +1,156 @@
1
+ //! Docker image update and rollback operations
2
+ //!
3
+ //! This module provides functionality to update the opencode image to the latest
4
+ //! version and rollback to a previous version if needed.
5
+
6
+ use super::image::{image_exists, pull_image};
7
+ use super::progress::ProgressReporter;
8
+ use super::{DockerClient, DockerError, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
9
+ use bollard::image::TagImageOptions;
10
+ use tracing::debug;
11
+
12
+ /// Tag for the previous image version (used for rollback)
13
+ pub const PREVIOUS_TAG: &str = "previous";
14
+
15
+ /// Result of an update operation
16
+ #[derive(Debug, Clone, PartialEq)]
17
+ pub enum UpdateResult {
18
+ /// Update completed successfully
19
+ Success,
20
+ /// Already on the latest version
21
+ AlreadyLatest,
22
+ }
23
+
24
+ /// Tag the current image as "previous" for rollback support
25
+ ///
26
+ /// This allows users to rollback to the version they had before updating.
27
+ /// If the current image doesn't exist, this is silently skipped.
28
+ ///
29
+ /// # Arguments
30
+ /// * `client` - Docker client
31
+ pub async fn tag_current_as_previous(client: &DockerClient) -> Result<(), DockerError> {
32
+ let current_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
33
+ let previous_image = format!("{IMAGE_NAME_GHCR}:{PREVIOUS_TAG}");
34
+
35
+ debug!(
36
+ "Tagging current image {} as {}",
37
+ current_image, previous_image
38
+ );
39
+
40
+ // Check if current image exists
41
+ if !image_exists(client, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT).await? {
42
+ debug!("Current image not found, skipping backup tag");
43
+ return Ok(());
44
+ }
45
+
46
+ // Tag current as previous
47
+ let options = TagImageOptions {
48
+ repo: IMAGE_NAME_GHCR,
49
+ tag: PREVIOUS_TAG,
50
+ };
51
+
52
+ client
53
+ .inner()
54
+ .tag_image(&current_image, Some(options))
55
+ .await
56
+ .map_err(|e| {
57
+ DockerError::Container(format!("Failed to tag current image as previous: {e}"))
58
+ })?;
59
+
60
+ debug!("Successfully tagged current image as previous");
61
+ Ok(())
62
+ }
63
+
64
+ /// Check if a previous image exists for rollback
65
+ ///
66
+ /// Returns true if a rollback is possible, false otherwise.
67
+ ///
68
+ /// # Arguments
69
+ /// * `client` - Docker client
70
+ pub async fn has_previous_image(client: &DockerClient) -> Result<bool, DockerError> {
71
+ image_exists(client, IMAGE_NAME_GHCR, PREVIOUS_TAG).await
72
+ }
73
+
74
+ /// Update the opencode image to the latest version
75
+ ///
76
+ /// This operation:
77
+ /// 1. Tags the current image as "previous" for rollback
78
+ /// 2. Pulls the latest image from the registry
79
+ ///
80
+ /// Returns UpdateResult indicating success or if already on latest.
81
+ ///
82
+ /// # Arguments
83
+ /// * `client` - Docker client
84
+ /// * `progress` - Progress reporter for user feedback
85
+ pub async fn update_image(
86
+ client: &DockerClient,
87
+ progress: &mut ProgressReporter,
88
+ ) -> Result<UpdateResult, DockerError> {
89
+ // Step 1: Tag current image as previous for rollback
90
+ progress.add_spinner("backup", "Backing up current image");
91
+ tag_current_as_previous(client).await?;
92
+ progress.finish("backup", "Current image backed up");
93
+
94
+ // Step 2: Pull latest image
95
+ progress.add_spinner("pull", "Pulling latest image");
96
+ pull_image(client, Some(IMAGE_TAG_DEFAULT), progress).await?;
97
+ progress.finish("pull", "Latest image pulled");
98
+
99
+ Ok(UpdateResult::Success)
100
+ }
101
+
102
+ /// Rollback to the previous image version
103
+ ///
104
+ /// This re-tags the "previous" image as "latest", effectively reverting
105
+ /// to the version that was active before the last update.
106
+ ///
107
+ /// Returns an error if no previous image exists.
108
+ ///
109
+ /// # Arguments
110
+ /// * `client` - Docker client
111
+ pub async fn rollback_image(client: &DockerClient) -> Result<(), DockerError> {
112
+ // Check if previous image exists
113
+ if !has_previous_image(client).await? {
114
+ return Err(DockerError::Container(
115
+ "No previous image available for rollback. Update at least once before using rollback."
116
+ .to_string(),
117
+ ));
118
+ }
119
+
120
+ let previous_image = format!("{IMAGE_NAME_GHCR}:{PREVIOUS_TAG}");
121
+ let current_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
122
+
123
+ debug!("Rolling back from {} to {}", current_image, previous_image);
124
+
125
+ // Re-tag previous as latest
126
+ let options = TagImageOptions {
127
+ repo: IMAGE_NAME_GHCR,
128
+ tag: IMAGE_TAG_DEFAULT,
129
+ };
130
+
131
+ client
132
+ .inner()
133
+ .tag_image(&previous_image, Some(options))
134
+ .await
135
+ .map_err(|e| DockerError::Container(format!("Failed to rollback image: {e}")))?;
136
+
137
+ debug!("Successfully rolled back to previous image");
138
+ Ok(())
139
+ }
140
+
141
+ #[cfg(test)]
142
+ mod tests {
143
+ use super::*;
144
+
145
+ #[test]
146
+ fn previous_tag_constant() {
147
+ assert_eq!(PREVIOUS_TAG, "previous");
148
+ }
149
+
150
+ #[test]
151
+ fn update_result_variants() {
152
+ assert_eq!(UpdateResult::Success, UpdateResult::Success);
153
+ assert_eq!(UpdateResult::AlreadyLatest, UpdateResult::AlreadyLatest);
154
+ assert_ne!(UpdateResult::Success, UpdateResult::AlreadyLatest);
155
+ }
156
+ }