@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.
- package/Cargo.toml +9 -2
- package/README.md +56 -30
- package/package.json +1 -1
- package/src/config/mod.rs +8 -3
- package/src/config/paths.rs +14 -0
- package/src/config/schema.rs +561 -0
- package/src/config/validation.rs +271 -0
- package/src/docker/Dockerfile +353 -207
- package/src/docker/README.dockerhub.md +39 -0
- package/src/docker/client.rs +132 -3
- package/src/docker/container.rs +204 -36
- package/src/docker/dockerfile.rs +15 -12
- package/src/docker/exec.rs +278 -0
- package/src/docker/health.rs +165 -0
- package/src/docker/image.rs +2 -4
- package/src/docker/mod.rs +72 -8
- package/src/docker/mount.rs +330 -0
- package/src/docker/progress.rs +4 -4
- package/src/docker/state.rs +120 -0
- package/src/docker/update.rs +156 -0
- package/src/docker/users.rs +357 -0
- package/src/docker/version.rs +95 -0
- package/src/host/error.rs +61 -0
- package/src/host/mod.rs +29 -0
- package/src/host/provision.rs +394 -0
- package/src/host/schema.rs +308 -0
- package/src/host/ssh_config.rs +282 -0
- package/src/host/storage.rs +118 -0
- package/src/host/tunnel.rs +268 -0
- package/src/lib.rs +10 -1
- package/src/platform/launchd.rs +1 -1
- package/src/platform/systemd.rs +6 -6
- package/src/singleton/mod.rs +1 -1
- package/src/version.rs +1 -6
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# opencode-cloud-sandbox
|
|
2
|
+
|
|
3
|
+
Opinionated container image for AI-assisted coding with opencode.
|
|
4
|
+
|
|
5
|
+
## What is included
|
|
6
|
+
|
|
7
|
+
- Ubuntu 24.04 (noble)
|
|
8
|
+
- Non-root user with passwordless sudo
|
|
9
|
+
- mise-managed runtimes (Node.js LTS, Python 3.12, Go 1.24)
|
|
10
|
+
- Rust toolchain via rustup
|
|
11
|
+
- Core CLI utilities (ripgrep, eza, jq, git, etc.)
|
|
12
|
+
- Cockpit web console for administration
|
|
13
|
+
- opencode preinstalled with the GSD plugin
|
|
14
|
+
|
|
15
|
+
## Tags
|
|
16
|
+
|
|
17
|
+
- `latest`: Most recent published release
|
|
18
|
+
- `X.Y.Z`: Versioned releases (recommended for pinning)
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Pull the image:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
docker pull ghcr.io/prizz/opencode-cloud-sandbox:latest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run the container:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
docker run --rm -it -p 3000:3000 -p 9090:9090 ghcr.io/prizz/opencode-cloud-sandbox:latest
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The opencode web UI is available at `http://localhost:3000`. Cockpit runs on `http://localhost:9090`.
|
|
35
|
+
|
|
36
|
+
## Source
|
|
37
|
+
|
|
38
|
+
- Repository: https://github.com/pRizz/opencode-cloud
|
|
39
|
+
- Dockerfile: packages/core/src/docker/Dockerfile
|
package/src/docker/client.rs
CHANGED
|
@@ -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 {
|
|
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(
|
|
44
|
+
.with_timeout(Duration::from_secs(timeout_secs));
|
|
35
45
|
|
|
36
|
-
Ok(Self {
|
|
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
|
}
|
package/src/docker/container.rs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//! Docker containers for the opencode-cloud service.
|
|
5
5
|
|
|
6
6
|
use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
|
|
7
|
+
use super::mount::ParsedMount;
|
|
7
8
|
use super::volume::{
|
|
8
9
|
MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
|
|
9
10
|
};
|
|
@@ -12,7 +13,9 @@ use bollard::container::{
|
|
|
12
13
|
Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
|
13
14
|
StopContainerOptions,
|
|
14
15
|
};
|
|
15
|
-
use bollard::service::{
|
|
16
|
+
use bollard::service::{
|
|
17
|
+
HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
|
|
18
|
+
};
|
|
16
19
|
use std::collections::HashMap;
|
|
17
20
|
use tracing::debug;
|
|
18
21
|
|
|
@@ -33,28 +36,38 @@ pub const OPENCODE_WEB_PORT: u16 = 3000;
|
|
|
33
36
|
/// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
|
|
34
37
|
/// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
|
|
35
38
|
/// * `env_vars` - Additional environment variables (optional)
|
|
39
|
+
/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
|
|
40
|
+
/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
|
|
41
|
+
/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
|
|
42
|
+
/// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
|
|
43
|
+
#[allow(clippy::too_many_arguments)]
|
|
36
44
|
pub async fn create_container(
|
|
37
45
|
client: &DockerClient,
|
|
38
46
|
name: Option<&str>,
|
|
39
47
|
image: Option<&str>,
|
|
40
48
|
opencode_web_port: Option<u16>,
|
|
41
49
|
env_vars: Option<Vec<String>>,
|
|
50
|
+
bind_address: Option<&str>,
|
|
51
|
+
cockpit_port: Option<u16>,
|
|
52
|
+
cockpit_enabled: Option<bool>,
|
|
53
|
+
bind_mounts: Option<Vec<ParsedMount>>,
|
|
42
54
|
) -> Result<String, DockerError> {
|
|
43
55
|
let container_name = name.unwrap_or(CONTAINER_NAME);
|
|
44
56
|
let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
45
57
|
let image_name = image.unwrap_or(&default_image);
|
|
46
58
|
let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
|
|
59
|
+
let cockpit_port_val = cockpit_port.unwrap_or(9090);
|
|
60
|
+
let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
|
|
47
61
|
|
|
48
62
|
debug!(
|
|
49
|
-
"Creating container {} from image {} with port {}",
|
|
50
|
-
container_name, image_name, port
|
|
63
|
+
"Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
|
|
64
|
+
container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
|
|
51
65
|
);
|
|
52
66
|
|
|
53
67
|
// Check if container already exists
|
|
54
68
|
if container_exists(client, container_name).await? {
|
|
55
69
|
return Err(DockerError::Container(format!(
|
|
56
|
-
"Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name."
|
|
57
|
-
container_name
|
|
70
|
+
"Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
|
|
58
71
|
)));
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -68,13 +81,12 @@ pub async fn create_container(
|
|
|
68
81
|
|
|
69
82
|
if !super::image::image_exists(client, image_repo, image_tag).await? {
|
|
70
83
|
return Err(DockerError::Container(format!(
|
|
71
|
-
"Image '{}' not found. Run 'occ pull' first to download the image."
|
|
72
|
-
image_name
|
|
84
|
+
"Image '{image_name}' not found. Run 'occ pull' first to download the image."
|
|
73
85
|
)));
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
// Create volume mounts
|
|
77
|
-
let mounts = vec![
|
|
89
|
+
let mut mounts = vec![
|
|
78
90
|
Mount {
|
|
79
91
|
target: Some(MOUNT_SESSION.to_string()),
|
|
80
92
|
source: Some(VOLUME_SESSION.to_string()),
|
|
@@ -98,26 +110,92 @@ pub async fn create_container(
|
|
|
98
110
|
},
|
|
99
111
|
];
|
|
100
112
|
|
|
101
|
-
//
|
|
113
|
+
// Add user-defined bind mounts from config/CLI
|
|
114
|
+
if let Some(ref user_mounts) = bind_mounts {
|
|
115
|
+
for parsed in user_mounts {
|
|
116
|
+
mounts.push(parsed.to_bollard_mount());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Create port bindings (default to localhost for security)
|
|
121
|
+
let bind_addr = bind_address.unwrap_or("127.0.0.1");
|
|
102
122
|
let mut port_bindings: PortMap = HashMap::new();
|
|
123
|
+
|
|
124
|
+
// opencode web port
|
|
103
125
|
port_bindings.insert(
|
|
104
126
|
"3000/tcp".to_string(),
|
|
105
127
|
Some(vec![PortBinding {
|
|
106
|
-
host_ip: Some(
|
|
128
|
+
host_ip: Some(bind_addr.to_string()),
|
|
107
129
|
host_port: Some(port.to_string()),
|
|
108
130
|
}]),
|
|
109
131
|
);
|
|
110
132
|
|
|
133
|
+
// Cockpit port (if enabled)
|
|
134
|
+
// Container always listens on 9090, map to host's configured port
|
|
135
|
+
if cockpit_enabled_val {
|
|
136
|
+
port_bindings.insert(
|
|
137
|
+
"9090/tcp".to_string(),
|
|
138
|
+
Some(vec![PortBinding {
|
|
139
|
+
host_ip: Some(bind_addr.to_string()),
|
|
140
|
+
host_port: Some(cockpit_port_val.to_string()),
|
|
141
|
+
}]),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
111
145
|
// Create exposed ports map
|
|
112
146
|
let mut exposed_ports = HashMap::new();
|
|
113
147
|
exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
|
|
148
|
+
if cockpit_enabled_val {
|
|
149
|
+
exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
|
|
150
|
+
}
|
|
114
151
|
|
|
115
152
|
// Create host config
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
// When Cockpit is enabled, add systemd-specific settings (requires Linux host)
|
|
154
|
+
// When Cockpit is disabled, use simpler tini-based config (works everywhere)
|
|
155
|
+
let host_config = if cockpit_enabled_val {
|
|
156
|
+
HostConfig {
|
|
157
|
+
mounts: Some(mounts),
|
|
158
|
+
port_bindings: Some(port_bindings),
|
|
159
|
+
auto_remove: Some(false),
|
|
160
|
+
// CAP_SYS_ADMIN required for systemd cgroup access
|
|
161
|
+
cap_add: Some(vec!["SYS_ADMIN".to_string()]),
|
|
162
|
+
// tmpfs for /run, /run/lock, and /tmp (required for systemd)
|
|
163
|
+
tmpfs: Some(HashMap::from([
|
|
164
|
+
("/run".to_string(), "exec".to_string()),
|
|
165
|
+
("/run/lock".to_string(), String::new()),
|
|
166
|
+
("/tmp".to_string(), String::new()),
|
|
167
|
+
])),
|
|
168
|
+
// cgroup mount (read-write for systemd)
|
|
169
|
+
binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
|
|
170
|
+
// Use HOST cgroup namespace for systemd compatibility across Linux distros:
|
|
171
|
+
// - cgroups v2 (Amazon Linux 2023, Fedora 31+, Ubuntu 21.10+, Debian 11+): required
|
|
172
|
+
// - cgroups v1 (CentOS 7, Ubuntu 18.04, Debian 10): works fine
|
|
173
|
+
// - Docker Desktop (macOS/Windows VM): works fine
|
|
174
|
+
// Note: PRIVATE mode is more isolated but causes systemd to exit(255) on cgroups v2.
|
|
175
|
+
// Since we already use privileged mode, HOST namespace is acceptable.
|
|
176
|
+
cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
|
|
177
|
+
// Privileged mode required for systemd to manage cgroups and system services
|
|
178
|
+
privileged: Some(true),
|
|
179
|
+
..Default::default()
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Simple config for tini mode (works on macOS and Linux)
|
|
183
|
+
HostConfig {
|
|
184
|
+
mounts: Some(mounts),
|
|
185
|
+
port_bindings: Some(port_bindings),
|
|
186
|
+
auto_remove: Some(false),
|
|
187
|
+
..Default::default()
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Build environment variables
|
|
192
|
+
// Add USE_SYSTEMD=1 when Cockpit is enabled to tell entrypoint to use systemd
|
|
193
|
+
let final_env = if cockpit_enabled_val {
|
|
194
|
+
let mut env = env_vars.unwrap_or_default();
|
|
195
|
+
env.push("USE_SYSTEMD=1".to_string());
|
|
196
|
+
Some(env)
|
|
197
|
+
} else {
|
|
198
|
+
env_vars
|
|
121
199
|
};
|
|
122
200
|
|
|
123
201
|
// Create container config
|
|
@@ -126,7 +204,7 @@ pub async fn create_container(
|
|
|
126
204
|
hostname: Some(CONTAINER_NAME.to_string()),
|
|
127
205
|
working_dir: Some("/workspace".to_string()),
|
|
128
206
|
exposed_ports: Some(exposed_ports),
|
|
129
|
-
env:
|
|
207
|
+
env: final_env,
|
|
130
208
|
host_config: Some(host_config),
|
|
131
209
|
..Default::default()
|
|
132
210
|
};
|
|
@@ -145,11 +223,10 @@ pub async fn create_container(
|
|
|
145
223
|
let msg = e.to_string();
|
|
146
224
|
if msg.contains("port is already allocated") || msg.contains("address already in use") {
|
|
147
225
|
DockerError::Container(format!(
|
|
148
|
-
"Port {} is already in use. Stop the service using that port or use a different port with --port."
|
|
149
|
-
port
|
|
226
|
+
"Port {port} is already in use. Stop the service using that port or use a different port with --port."
|
|
150
227
|
))
|
|
151
228
|
} else {
|
|
152
|
-
DockerError::Container(format!("Failed to create container: {}"
|
|
229
|
+
DockerError::Container(format!("Failed to create container: {e}"))
|
|
153
230
|
}
|
|
154
231
|
})?;
|
|
155
232
|
|
|
@@ -165,9 +242,7 @@ pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), Do
|
|
|
165
242
|
.inner()
|
|
166
243
|
.start_container(name, None::<StartContainerOptions<String>>)
|
|
167
244
|
.await
|
|
168
|
-
.map_err(|e| {
|
|
169
|
-
DockerError::Container(format!("Failed to start container {}: {}", name, e))
|
|
170
|
-
})?;
|
|
245
|
+
.map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
|
|
171
246
|
|
|
172
247
|
debug!("Container {} started", name);
|
|
173
248
|
Ok(())
|
|
@@ -198,9 +273,9 @@ pub async fn stop_container(
|
|
|
198
273
|
// "container already stopped" is not an error
|
|
199
274
|
if msg.contains("is not running") || msg.contains("304") {
|
|
200
275
|
debug!("Container {} was already stopped", name);
|
|
201
|
-
return DockerError::Container(format!("Container '{}' is not running"
|
|
276
|
+
return DockerError::Container(format!("Container '{name}' is not running"));
|
|
202
277
|
}
|
|
203
|
-
DockerError::Container(format!("Failed to stop container {}: {}"
|
|
278
|
+
DockerError::Container(format!("Failed to stop container {name}: {e}"))
|
|
204
279
|
})?;
|
|
205
280
|
|
|
206
281
|
debug!("Container {} stopped", name);
|
|
@@ -230,9 +305,7 @@ pub async fn remove_container(
|
|
|
230
305
|
.inner()
|
|
231
306
|
.remove_container(name, Some(options))
|
|
232
307
|
.await
|
|
233
|
-
.map_err(|e| {
|
|
234
|
-
DockerError::Container(format!("Failed to remove container {}: {}", name, e))
|
|
235
|
-
})?;
|
|
308
|
+
.map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
|
|
236
309
|
|
|
237
310
|
debug!("Container {} removed", name);
|
|
238
311
|
Ok(())
|
|
@@ -248,8 +321,7 @@ pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool,
|
|
|
248
321
|
status_code: 404, ..
|
|
249
322
|
}) => Ok(false),
|
|
250
323
|
Err(e) => Err(DockerError::Container(format!(
|
|
251
|
-
"Failed to inspect container {}: {}"
|
|
252
|
-
name, e
|
|
324
|
+
"Failed to inspect container {name}: {e}"
|
|
253
325
|
))),
|
|
254
326
|
}
|
|
255
327
|
}
|
|
@@ -267,8 +339,7 @@ pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<b
|
|
|
267
339
|
status_code: 404, ..
|
|
268
340
|
}) => Ok(false),
|
|
269
341
|
Err(e) => Err(DockerError::Container(format!(
|
|
270
|
-
"Failed to inspect container {}: {}"
|
|
271
|
-
name, e
|
|
342
|
+
"Failed to inspect container {name}: {e}"
|
|
272
343
|
))),
|
|
273
344
|
}
|
|
274
345
|
}
|
|
@@ -289,16 +360,113 @@ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String
|
|
|
289
360
|
Err(bollard::errors::Error::DockerResponseServerError {
|
|
290
361
|
status_code: 404, ..
|
|
291
362
|
}) => Err(DockerError::Container(format!(
|
|
292
|
-
"Container '{}' not found"
|
|
293
|
-
name
|
|
363
|
+
"Container '{name}' not found"
|
|
294
364
|
))),
|
|
295
365
|
Err(e) => Err(DockerError::Container(format!(
|
|
296
|
-
"Failed to inspect container {}: {}"
|
|
297
|
-
name, e
|
|
366
|
+
"Failed to inspect container {name}: {e}"
|
|
298
367
|
))),
|
|
299
368
|
}
|
|
300
369
|
}
|
|
301
370
|
|
|
371
|
+
/// Container port configuration
|
|
372
|
+
#[derive(Debug, Clone)]
|
|
373
|
+
pub struct ContainerPorts {
|
|
374
|
+
/// Host port for opencode web UI (mapped from container port 3000)
|
|
375
|
+
pub opencode_port: Option<u16>,
|
|
376
|
+
/// Host port for Cockpit (mapped from container port 9090)
|
|
377
|
+
pub cockpit_port: Option<u16>,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/// A bind mount from an existing container
|
|
381
|
+
#[derive(Debug, Clone)]
|
|
382
|
+
pub struct ContainerBindMount {
|
|
383
|
+
/// Source path on host
|
|
384
|
+
pub source: String,
|
|
385
|
+
/// Target path in container
|
|
386
|
+
pub target: String,
|
|
387
|
+
/// Read-only flag
|
|
388
|
+
pub read_only: bool,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Get the port bindings from an existing container
|
|
392
|
+
///
|
|
393
|
+
/// Returns the host ports that the container's internal ports are mapped to.
|
|
394
|
+
/// Returns None for ports that aren't mapped.
|
|
395
|
+
pub async fn get_container_ports(
|
|
396
|
+
client: &DockerClient,
|
|
397
|
+
name: &str,
|
|
398
|
+
) -> Result<ContainerPorts, DockerError> {
|
|
399
|
+
debug!("Getting container ports: {}", name);
|
|
400
|
+
|
|
401
|
+
let info = client
|
|
402
|
+
.inner()
|
|
403
|
+
.inspect_container(name, None)
|
|
404
|
+
.await
|
|
405
|
+
.map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
|
|
406
|
+
|
|
407
|
+
let port_bindings = info
|
|
408
|
+
.host_config
|
|
409
|
+
.and_then(|hc| hc.port_bindings)
|
|
410
|
+
.unwrap_or_default();
|
|
411
|
+
|
|
412
|
+
// Extract opencode port (3000/tcp -> host port)
|
|
413
|
+
let opencode_port = port_bindings
|
|
414
|
+
.get("3000/tcp")
|
|
415
|
+
.and_then(|bindings| bindings.as_ref())
|
|
416
|
+
.and_then(|bindings| bindings.first())
|
|
417
|
+
.and_then(|binding| binding.host_port.as_ref())
|
|
418
|
+
.and_then(|port_str| port_str.parse::<u16>().ok());
|
|
419
|
+
|
|
420
|
+
// Extract cockpit port (9090/tcp -> host port)
|
|
421
|
+
let cockpit_port = port_bindings
|
|
422
|
+
.get("9090/tcp")
|
|
423
|
+
.and_then(|bindings| bindings.as_ref())
|
|
424
|
+
.and_then(|bindings| bindings.first())
|
|
425
|
+
.and_then(|binding| binding.host_port.as_ref())
|
|
426
|
+
.and_then(|port_str| port_str.parse::<u16>().ok());
|
|
427
|
+
|
|
428
|
+
Ok(ContainerPorts {
|
|
429
|
+
opencode_port,
|
|
430
|
+
cockpit_port,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// Get bind mounts from an existing container
|
|
435
|
+
///
|
|
436
|
+
/// Returns only user-defined bind mounts (excludes system mounts like cgroup).
|
|
437
|
+
pub async fn get_container_bind_mounts(
|
|
438
|
+
client: &DockerClient,
|
|
439
|
+
name: &str,
|
|
440
|
+
) -> Result<Vec<ContainerBindMount>, DockerError> {
|
|
441
|
+
debug!("Getting container bind mounts: {}", name);
|
|
442
|
+
|
|
443
|
+
let info = client
|
|
444
|
+
.inner()
|
|
445
|
+
.inspect_container(name, None)
|
|
446
|
+
.await
|
|
447
|
+
.map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
|
|
448
|
+
|
|
449
|
+
let mounts = info.mounts.unwrap_or_default();
|
|
450
|
+
|
|
451
|
+
// Filter to only bind mounts, excluding system paths
|
|
452
|
+
let bind_mounts: Vec<ContainerBindMount> = mounts
|
|
453
|
+
.iter()
|
|
454
|
+
.filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
|
|
455
|
+
.filter(|m| {
|
|
456
|
+
// Exclude system mounts (cgroup, etc.)
|
|
457
|
+
let target = m.destination.as_deref().unwrap_or("");
|
|
458
|
+
!target.starts_with("/sys/")
|
|
459
|
+
})
|
|
460
|
+
.map(|m| ContainerBindMount {
|
|
461
|
+
source: m.source.clone().unwrap_or_default(),
|
|
462
|
+
target: m.destination.clone().unwrap_or_default(),
|
|
463
|
+
read_only: m.rw.map(|rw| !rw).unwrap_or(false),
|
|
464
|
+
})
|
|
465
|
+
.collect();
|
|
466
|
+
|
|
467
|
+
Ok(bind_mounts)
|
|
468
|
+
}
|
|
469
|
+
|
|
302
470
|
#[cfg(test)]
|
|
303
471
|
mod tests {
|
|
304
472
|
use super::*;
|
|
@@ -312,6 +480,6 @@ mod tests {
|
|
|
312
480
|
#[test]
|
|
313
481
|
fn default_image_format() {
|
|
314
482
|
let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
315
|
-
assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
|
|
483
|
+
assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
|
|
316
484
|
}
|
|
317
485
|
}
|
package/src/docker/dockerfile.rs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
//! Embedded Dockerfile content
|
|
2
2
|
//!
|
|
3
|
-
//! This module contains the Dockerfile for building the opencode-cloud
|
|
4
|
-
//! embedded at compile time for distribution with the CLI.
|
|
3
|
+
//! This module contains the Dockerfile for building the opencode-cloud-sandbox
|
|
4
|
+
//! container image, embedded at compile time for distribution with the CLI.
|
|
5
|
+
//!
|
|
6
|
+
//! Note: The image is named "opencode-cloud-sandbox" (not "opencode-cloud") to
|
|
7
|
+
//! clearly indicate this is the sandboxed container environment that the
|
|
8
|
+
//! opencode-cloud CLI deploys, not the CLI tool itself.
|
|
5
9
|
|
|
6
|
-
/// The Dockerfile for building the opencode-cloud container image
|
|
10
|
+
/// The Dockerfile for building the opencode-cloud-sandbox container image
|
|
7
11
|
pub const DOCKERFILE: &str = include_str!("Dockerfile");
|
|
8
12
|
|
|
9
13
|
// =============================================================================
|
|
@@ -15,27 +19,26 @@ pub const DOCKERFILE: &str = include_str!("Dockerfile");
|
|
|
15
19
|
// - Registry: The server hosting the image (e.g., ghcr.io, gcr.io, docker.io)
|
|
16
20
|
// When omitted, Docker Hub (docker.io) is assumed.
|
|
17
21
|
// - Namespace: Usually the username or organization (e.g., prizz)
|
|
18
|
-
// - Image: The image name (e.g., opencode-cloud)
|
|
22
|
+
// - Image: The image name (e.g., opencode-cloud-sandbox)
|
|
19
23
|
// - Tag: Version identifier (e.g., latest, v1.0.0). Defaults to "latest" if omitted.
|
|
20
24
|
//
|
|
21
25
|
// 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
|
|
26
|
+
// ghcr.io/prizz/opencode-cloud-sandbox:latest - GitHub Container Registry
|
|
27
|
+
// prizz/opencode-cloud-sandbox:latest - Docker Hub (registry omitted)
|
|
28
|
+
// gcr.io/my-project/myapp:v1.0 - Google Container Registry
|
|
25
29
|
//
|
|
26
|
-
// We
|
|
27
|
-
// Actions for CI/CD publishing.
|
|
30
|
+
// We publish to both GHCR (primary) and Docker Hub for maximum accessibility.
|
|
28
31
|
// =============================================================================
|
|
29
32
|
|
|
30
33
|
/// Docker image name for GitHub Container Registry (primary registry)
|
|
31
34
|
///
|
|
32
35
|
/// Format: `ghcr.io/{github-username}/{image-name}`
|
|
33
|
-
pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud";
|
|
36
|
+
pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud-sandbox";
|
|
34
37
|
|
|
35
|
-
/// Docker image name for Docker Hub (
|
|
38
|
+
/// Docker image name for Docker Hub (secondary registry)
|
|
36
39
|
///
|
|
37
40
|
/// Format: `{dockerhub-username}/{image-name}` (registry prefix omitted for Docker Hub)
|
|
38
|
-
pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud";
|
|
41
|
+
pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud-sandbox";
|
|
39
42
|
|
|
40
43
|
/// Default image tag
|
|
41
44
|
pub const IMAGE_TAG_DEFAULT: &str = "latest";
|