@opencode-cloud/core 0.1.2 → 1.0.3

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,317 @@
1
+ //! Docker container lifecycle management
2
+ //!
3
+ //! This module provides functions to create, start, stop, and remove
4
+ //! Docker containers for the opencode-cloud service.
5
+
6
+ use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7
+ use super::volume::{
8
+ MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
9
+ };
10
+ use super::{DockerClient, DockerError};
11
+ use bollard::container::{
12
+ Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
13
+ StopContainerOptions,
14
+ };
15
+ use bollard::service::{HostConfig, Mount, MountTypeEnum, PortBinding, PortMap};
16
+ use std::collections::HashMap;
17
+ use tracing::debug;
18
+
19
+ /// Default container name
20
+ pub const CONTAINER_NAME: &str = "opencode-cloud";
21
+
22
+ /// Default port for opencode web UI
23
+ pub const OPENCODE_WEB_PORT: u16 = 3000;
24
+
25
+ /// Create the opencode container with volume mounts
26
+ ///
27
+ /// Does not start the container - use start_container after creation.
28
+ /// Returns the container ID on success.
29
+ ///
30
+ /// # Arguments
31
+ /// * `client` - Docker client
32
+ /// * `name` - Container name (defaults to CONTAINER_NAME)
33
+ /// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
34
+ /// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
35
+ /// * `env_vars` - Additional environment variables (optional)
36
+ pub async fn create_container(
37
+ client: &DockerClient,
38
+ name: Option<&str>,
39
+ image: Option<&str>,
40
+ opencode_web_port: Option<u16>,
41
+ env_vars: Option<Vec<String>>,
42
+ ) -> Result<String, DockerError> {
43
+ let container_name = name.unwrap_or(CONTAINER_NAME);
44
+ let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
45
+ let image_name = image.unwrap_or(&default_image);
46
+ let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
47
+
48
+ debug!(
49
+ "Creating container {} from image {} with port {}",
50
+ container_name, image_name, port
51
+ );
52
+
53
+ // Check if container already exists
54
+ if container_exists(client, container_name).await? {
55
+ return Err(DockerError::Container(format!(
56
+ "Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name.",
57
+ container_name
58
+ )));
59
+ }
60
+
61
+ // Check if image exists
62
+ let image_parts: Vec<&str> = image_name.split(':').collect();
63
+ let (image_repo, image_tag) = if image_parts.len() == 2 {
64
+ (image_parts[0], image_parts[1])
65
+ } else {
66
+ (image_name, "latest")
67
+ };
68
+
69
+ if !super::image::image_exists(client, image_repo, image_tag).await? {
70
+ return Err(DockerError::Container(format!(
71
+ "Image '{}' not found. Run 'occ pull' first to download the image.",
72
+ image_name
73
+ )));
74
+ }
75
+
76
+ // Create volume mounts
77
+ let mounts = vec![
78
+ Mount {
79
+ target: Some(MOUNT_SESSION.to_string()),
80
+ source: Some(VOLUME_SESSION.to_string()),
81
+ typ: Some(MountTypeEnum::VOLUME),
82
+ read_only: Some(false),
83
+ ..Default::default()
84
+ },
85
+ Mount {
86
+ target: Some(MOUNT_PROJECTS.to_string()),
87
+ source: Some(VOLUME_PROJECTS.to_string()),
88
+ typ: Some(MountTypeEnum::VOLUME),
89
+ read_only: Some(false),
90
+ ..Default::default()
91
+ },
92
+ Mount {
93
+ target: Some(MOUNT_CONFIG.to_string()),
94
+ source: Some(VOLUME_CONFIG.to_string()),
95
+ typ: Some(MountTypeEnum::VOLUME),
96
+ read_only: Some(false),
97
+ ..Default::default()
98
+ },
99
+ ];
100
+
101
+ // Create port bindings (localhost only for security)
102
+ let mut port_bindings: PortMap = HashMap::new();
103
+ port_bindings.insert(
104
+ "3000/tcp".to_string(),
105
+ Some(vec![PortBinding {
106
+ host_ip: Some("127.0.0.1".to_string()),
107
+ host_port: Some(port.to_string()),
108
+ }]),
109
+ );
110
+
111
+ // Create exposed ports map
112
+ let mut exposed_ports = HashMap::new();
113
+ exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
114
+
115
+ // 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()
121
+ };
122
+
123
+ // Create container config
124
+ let config = Config {
125
+ image: Some(image_name.to_string()),
126
+ hostname: Some(CONTAINER_NAME.to_string()),
127
+ working_dir: Some("/workspace".to_string()),
128
+ exposed_ports: Some(exposed_ports),
129
+ env: env_vars,
130
+ host_config: Some(host_config),
131
+ ..Default::default()
132
+ };
133
+
134
+ // Create container
135
+ let options = CreateContainerOptions {
136
+ name: container_name,
137
+ platform: None,
138
+ };
139
+
140
+ let response = client
141
+ .inner()
142
+ .create_container(Some(options), config)
143
+ .await
144
+ .map_err(|e| {
145
+ let msg = e.to_string();
146
+ if msg.contains("port is already allocated") || msg.contains("address already in use") {
147
+ DockerError::Container(format!(
148
+ "Port {} is already in use. Stop the service using that port or use a different port with --port.",
149
+ port
150
+ ))
151
+ } else {
152
+ DockerError::Container(format!("Failed to create container: {}", e))
153
+ }
154
+ })?;
155
+
156
+ debug!("Container created with ID: {}", response.id);
157
+ Ok(response.id)
158
+ }
159
+
160
+ /// Start an existing container
161
+ pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
162
+ debug!("Starting container: {}", name);
163
+
164
+ client
165
+ .inner()
166
+ .start_container(name, None::<StartContainerOptions<String>>)
167
+ .await
168
+ .map_err(|e| {
169
+ DockerError::Container(format!("Failed to start container {}: {}", name, e))
170
+ })?;
171
+
172
+ debug!("Container {} started", name);
173
+ Ok(())
174
+ }
175
+
176
+ /// Stop a running container with graceful shutdown
177
+ ///
178
+ /// # Arguments
179
+ /// * `client` - Docker client
180
+ /// * `name` - Container name
181
+ /// * `timeout_secs` - Seconds to wait before force kill (default: 10)
182
+ pub async fn stop_container(
183
+ client: &DockerClient,
184
+ name: &str,
185
+ timeout_secs: Option<i64>,
186
+ ) -> Result<(), DockerError> {
187
+ let timeout = timeout_secs.unwrap_or(10);
188
+ debug!("Stopping container {} with {}s timeout", name, timeout);
189
+
190
+ let options = StopContainerOptions { t: timeout };
191
+
192
+ client
193
+ .inner()
194
+ .stop_container(name, Some(options))
195
+ .await
196
+ .map_err(|e| {
197
+ let msg = e.to_string();
198
+ // "container already stopped" is not an error
199
+ if msg.contains("is not running") || msg.contains("304") {
200
+ debug!("Container {} was already stopped", name);
201
+ return DockerError::Container(format!("Container '{}' is not running", name));
202
+ }
203
+ DockerError::Container(format!("Failed to stop container {}: {}", name, e))
204
+ })?;
205
+
206
+ debug!("Container {} stopped", name);
207
+ Ok(())
208
+ }
209
+
210
+ /// Remove a container
211
+ ///
212
+ /// # Arguments
213
+ /// * `client` - Docker client
214
+ /// * `name` - Container name
215
+ /// * `force` - Remove even if running
216
+ pub async fn remove_container(
217
+ client: &DockerClient,
218
+ name: &str,
219
+ force: bool,
220
+ ) -> Result<(), DockerError> {
221
+ debug!("Removing container {} (force={})", name, force);
222
+
223
+ let options = RemoveContainerOptions {
224
+ force,
225
+ v: false, // Don't remove volumes
226
+ link: false,
227
+ };
228
+
229
+ client
230
+ .inner()
231
+ .remove_container(name, Some(options))
232
+ .await
233
+ .map_err(|e| {
234
+ DockerError::Container(format!("Failed to remove container {}: {}", name, e))
235
+ })?;
236
+
237
+ debug!("Container {} removed", name);
238
+ Ok(())
239
+ }
240
+
241
+ /// Check if container exists
242
+ pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
243
+ debug!("Checking if container exists: {}", name);
244
+
245
+ match client.inner().inspect_container(name, None).await {
246
+ Ok(_) => Ok(true),
247
+ Err(bollard::errors::Error::DockerResponseServerError {
248
+ status_code: 404, ..
249
+ }) => Ok(false),
250
+ Err(e) => Err(DockerError::Container(format!(
251
+ "Failed to inspect container {}: {}",
252
+ name, e
253
+ ))),
254
+ }
255
+ }
256
+
257
+ /// Check if container is running
258
+ pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
259
+ debug!("Checking if container is running: {}", name);
260
+
261
+ match client.inner().inspect_container(name, None).await {
262
+ Ok(info) => {
263
+ let running = info.state.and_then(|s| s.running).unwrap_or(false);
264
+ Ok(running)
265
+ }
266
+ Err(bollard::errors::Error::DockerResponseServerError {
267
+ status_code: 404, ..
268
+ }) => Ok(false),
269
+ Err(e) => Err(DockerError::Container(format!(
270
+ "Failed to inspect container {}: {}",
271
+ name, e
272
+ ))),
273
+ }
274
+ }
275
+
276
+ /// Get container state (running, stopped, etc.)
277
+ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
278
+ debug!("Getting container state: {}", name);
279
+
280
+ match client.inner().inspect_container(name, None).await {
281
+ Ok(info) => {
282
+ let state = info
283
+ .state
284
+ .and_then(|s| s.status)
285
+ .map(|s| s.to_string())
286
+ .unwrap_or_else(|| "unknown".to_string());
287
+ Ok(state)
288
+ }
289
+ Err(bollard::errors::Error::DockerResponseServerError {
290
+ status_code: 404, ..
291
+ }) => Err(DockerError::Container(format!(
292
+ "Container '{}' not found",
293
+ name
294
+ ))),
295
+ Err(e) => Err(DockerError::Container(format!(
296
+ "Failed to inspect container {}: {}",
297
+ name, e
298
+ ))),
299
+ }
300
+ }
301
+
302
+ #[cfg(test)]
303
+ mod tests {
304
+ use super::*;
305
+
306
+ #[test]
307
+ fn container_constants_are_correct() {
308
+ assert_eq!(CONTAINER_NAME, "opencode-cloud");
309
+ assert_eq!(OPENCODE_WEB_PORT, 3000);
310
+ }
311
+
312
+ #[test]
313
+ fn default_image_format() {
314
+ let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
315
+ assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
316
+ }
317
+ }
@@ -0,0 +1,41 @@
1
+ //! Embedded Dockerfile content
2
+ //!
3
+ //! This module contains the Dockerfile for building the opencode-cloud container image,
4
+ //! embedded at compile time for distribution with the CLI.
5
+
6
+ /// The Dockerfile for building the opencode-cloud container image
7
+ pub const DOCKERFILE: &str = include_str!("Dockerfile");
8
+
9
+ // =============================================================================
10
+ // Docker Image Naming
11
+ // =============================================================================
12
+ //
13
+ // Docker images follow the naming convention: [registry/]namespace/image[:tag]
14
+ //
15
+ // - Registry: The server hosting the image (e.g., ghcr.io, gcr.io, docker.io)
16
+ // When omitted, Docker Hub (docker.io) is assumed.
17
+ // - Namespace: Usually the username or organization (e.g., prizz)
18
+ // - Image: The image name (e.g., opencode-cloud)
19
+ // - Tag: Version identifier (e.g., latest, v1.0.0). Defaults to "latest" if omitted.
20
+ //
21
+ // Examples:
22
+ // ghcr.io/prizz/opencode-cloud:latest - GitHub Container Registry
23
+ // prizz/opencode-cloud:latest - Docker Hub (registry omitted)
24
+ // gcr.io/my-project/myapp:v1.0 - Google Container Registry
25
+ //
26
+ // We use GHCR as the primary registry since it integrates well with GitHub
27
+ // Actions for CI/CD publishing.
28
+ // =============================================================================
29
+
30
+ /// Docker image name for GitHub Container Registry (primary registry)
31
+ ///
32
+ /// Format: `ghcr.io/{github-username}/{image-name}`
33
+ pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud";
34
+
35
+ /// Docker image name for Docker Hub (fallback registry)
36
+ ///
37
+ /// Format: `{dockerhub-username}/{image-name}` (registry prefix omitted for Docker Hub)
38
+ pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud";
39
+
40
+ /// Default image tag
41
+ pub const IMAGE_TAG_DEFAULT: &str = "latest";
@@ -0,0 +1,79 @@
1
+ //! Docker-specific error types
2
+ //!
3
+ //! This module defines errors that can occur during Docker operations,
4
+ //! providing clear, actionable messages for common issues.
5
+
6
+ use thiserror::Error;
7
+
8
+ /// Errors that can occur during Docker operations
9
+ #[derive(Error, Debug)]
10
+ pub enum DockerError {
11
+ /// Failed to connect to the Docker daemon
12
+ #[error("Docker connection failed: {0}")]
13
+ Connection(String),
14
+
15
+ /// Docker daemon is not running
16
+ #[error("Docker daemon not running. Start Docker Desktop or the Docker service.")]
17
+ NotRunning,
18
+
19
+ /// Permission denied accessing Docker socket
20
+ #[error(
21
+ "Permission denied accessing Docker socket. You may need to add your user to the 'docker' group."
22
+ )]
23
+ PermissionDenied,
24
+
25
+ /// Failed to build Docker image
26
+ #[error("Docker build failed: {0}")]
27
+ Build(String),
28
+
29
+ /// Failed to pull Docker image
30
+ #[error("Docker pull failed: {0}")]
31
+ Pull(String),
32
+
33
+ /// Container operation failed
34
+ #[error("Container operation failed: {0}")]
35
+ Container(String),
36
+
37
+ /// Volume operation failed
38
+ #[error("Volume operation failed: {0}")]
39
+ Volume(String),
40
+
41
+ /// Operation timed out
42
+ #[error("Docker operation timed out")]
43
+ Timeout,
44
+ }
45
+
46
+ impl From<bollard::errors::Error> for DockerError {
47
+ fn from(err: bollard::errors::Error) -> Self {
48
+ let msg = err.to_string();
49
+
50
+ // Detect common error patterns and provide better messages
51
+ if msg.contains("Cannot connect to the Docker daemon")
52
+ || msg.contains("connection refused")
53
+ || msg.contains("No such file or directory")
54
+ {
55
+ DockerError::NotRunning
56
+ } else if msg.contains("permission denied") || msg.contains("Permission denied") {
57
+ DockerError::PermissionDenied
58
+ } else {
59
+ DockerError::Connection(msg)
60
+ }
61
+ }
62
+ }
63
+
64
+ #[cfg(test)]
65
+ mod tests {
66
+ use super::*;
67
+
68
+ #[test]
69
+ fn docker_error_displays_correctly() {
70
+ let err = DockerError::NotRunning;
71
+ assert!(err.to_string().contains("Docker daemon not running"));
72
+
73
+ let err = DockerError::PermissionDenied;
74
+ assert!(err.to_string().contains("Permission denied"));
75
+
76
+ let err = DockerError::Build("layer failed".to_string());
77
+ assert!(err.to_string().contains("layer failed"));
78
+ }
79
+ }