@opencode-cloud/core 0.1.3 → 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.
- package/Cargo.toml +13 -0
- package/README.md +172 -0
- package/core.darwin-arm64.node +0 -0
- package/package.json +1 -1
- package/src/config/schema.rs +62 -8
- package/src/docker/Dockerfile +444 -0
- package/src/docker/client.rs +84 -0
- package/src/docker/container.rs +317 -0
- package/src/docker/dockerfile.rs +41 -0
- package/src/docker/error.rs +79 -0
- package/src/docker/image.rs +502 -0
- package/src/docker/mod.rs +112 -0
- package/src/docker/progress.rs +401 -0
- package/src/docker/volume.rs +144 -0
- package/src/lib.rs +14 -0
- package/src/platform/launchd.rs +363 -0
- package/src/platform/mod.rs +191 -0
- package/src/platform/systemd.rs +346 -0
|
@@ -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
|
+
}
|