@opencode-cloud/core 1.0.7 → 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.
- package/Cargo.toml +39 -32
- package/README.md +17 -0
- package/package.json +1 -1
- package/src/config/mod.rs +8 -3
- package/src/config/paths.rs +14 -0
- package/src/config/schema.rs +470 -0
- package/src/config/validation.rs +271 -0
- package/src/docker/Dockerfile +278 -153
- package/src/docker/client.rs +132 -3
- package/src/docker/container.rs +90 -33
- 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 +47 -4
- package/src/docker/progress.rs +4 -4
- 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
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
|
@@ -33,28 +33,36 @@ pub const OPENCODE_WEB_PORT: u16 = 3000;
|
|
|
33
33
|
/// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
|
|
34
34
|
/// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
|
|
35
35
|
/// * `env_vars` - Additional environment variables (optional)
|
|
36
|
+
/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
|
|
37
|
+
/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
|
|
38
|
+
/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
|
|
39
|
+
#[allow(clippy::too_many_arguments)]
|
|
36
40
|
pub async fn create_container(
|
|
37
41
|
client: &DockerClient,
|
|
38
42
|
name: Option<&str>,
|
|
39
43
|
image: Option<&str>,
|
|
40
44
|
opencode_web_port: Option<u16>,
|
|
41
45
|
env_vars: Option<Vec<String>>,
|
|
46
|
+
bind_address: Option<&str>,
|
|
47
|
+
cockpit_port: Option<u16>,
|
|
48
|
+
cockpit_enabled: Option<bool>,
|
|
42
49
|
) -> Result<String, DockerError> {
|
|
43
50
|
let container_name = name.unwrap_or(CONTAINER_NAME);
|
|
44
51
|
let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
45
52
|
let image_name = image.unwrap_or(&default_image);
|
|
46
53
|
let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
|
|
54
|
+
let cockpit_port_val = cockpit_port.unwrap_or(9090);
|
|
55
|
+
let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
|
|
47
56
|
|
|
48
57
|
debug!(
|
|
49
|
-
"Creating container {} from image {} with port {}",
|
|
50
|
-
container_name, image_name, port
|
|
58
|
+
"Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
|
|
59
|
+
container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
|
|
51
60
|
);
|
|
52
61
|
|
|
53
62
|
// Check if container already exists
|
|
54
63
|
if container_exists(client, container_name).await? {
|
|
55
64
|
return Err(DockerError::Container(format!(
|
|
56
|
-
"Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name."
|
|
57
|
-
container_name
|
|
65
|
+
"Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
|
|
58
66
|
)));
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -68,8 +76,7 @@ pub async fn create_container(
|
|
|
68
76
|
|
|
69
77
|
if !super::image::image_exists(client, image_repo, image_tag).await? {
|
|
70
78
|
return Err(DockerError::Container(format!(
|
|
71
|
-
"Image '{}' not found. Run 'occ pull' first to download the image."
|
|
72
|
-
image_name
|
|
79
|
+
"Image '{image_name}' not found. Run 'occ pull' first to download the image."
|
|
73
80
|
)));
|
|
74
81
|
}
|
|
75
82
|
|
|
@@ -98,26 +105,85 @@ pub async fn create_container(
|
|
|
98
105
|
},
|
|
99
106
|
];
|
|
100
107
|
|
|
101
|
-
// Create port bindings (localhost
|
|
108
|
+
// Create port bindings (default to localhost for security)
|
|
109
|
+
let bind_addr = bind_address.unwrap_or("127.0.0.1");
|
|
102
110
|
let mut port_bindings: PortMap = HashMap::new();
|
|
111
|
+
|
|
112
|
+
// opencode web port
|
|
103
113
|
port_bindings.insert(
|
|
104
114
|
"3000/tcp".to_string(),
|
|
105
115
|
Some(vec![PortBinding {
|
|
106
|
-
host_ip: Some(
|
|
116
|
+
host_ip: Some(bind_addr.to_string()),
|
|
107
117
|
host_port: Some(port.to_string()),
|
|
108
118
|
}]),
|
|
109
119
|
);
|
|
110
120
|
|
|
121
|
+
// Cockpit port (if enabled)
|
|
122
|
+
// Container always listens on 9090, map to host's configured port
|
|
123
|
+
if cockpit_enabled_val {
|
|
124
|
+
port_bindings.insert(
|
|
125
|
+
"9090/tcp".to_string(),
|
|
126
|
+
Some(vec![PortBinding {
|
|
127
|
+
host_ip: Some(bind_addr.to_string()),
|
|
128
|
+
host_port: Some(cockpit_port_val.to_string()),
|
|
129
|
+
}]),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
111
133
|
// Create exposed ports map
|
|
112
134
|
let mut exposed_ports = HashMap::new();
|
|
113
135
|
exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
|
|
136
|
+
if cockpit_enabled_val {
|
|
137
|
+
exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
|
|
138
|
+
}
|
|
114
139
|
|
|
115
140
|
// Create host config
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
141
|
+
// When Cockpit is enabled, add systemd-specific settings (requires Linux host)
|
|
142
|
+
// When Cockpit is disabled, use simpler tini-based config (works everywhere)
|
|
143
|
+
let host_config = if cockpit_enabled_val {
|
|
144
|
+
HostConfig {
|
|
145
|
+
mounts: Some(mounts),
|
|
146
|
+
port_bindings: Some(port_bindings),
|
|
147
|
+
auto_remove: Some(false),
|
|
148
|
+
// CAP_SYS_ADMIN required for systemd cgroup access
|
|
149
|
+
cap_add: Some(vec!["SYS_ADMIN".to_string()]),
|
|
150
|
+
// tmpfs for /run, /run/lock, and /tmp (required for systemd)
|
|
151
|
+
tmpfs: Some(HashMap::from([
|
|
152
|
+
("/run".to_string(), "exec".to_string()),
|
|
153
|
+
("/run/lock".to_string(), String::new()),
|
|
154
|
+
("/tmp".to_string(), String::new()),
|
|
155
|
+
])),
|
|
156
|
+
// cgroup mount (read-write for systemd)
|
|
157
|
+
binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
|
|
158
|
+
// Use HOST cgroup namespace for systemd compatibility across Linux distros:
|
|
159
|
+
// - cgroups v2 (Amazon Linux 2023, Fedora 31+, Ubuntu 21.10+, Debian 11+): required
|
|
160
|
+
// - cgroups v1 (CentOS 7, Ubuntu 18.04, Debian 10): works fine
|
|
161
|
+
// - Docker Desktop (macOS/Windows VM): works fine
|
|
162
|
+
// Note: PRIVATE mode is more isolated but causes systemd to exit(255) on cgroups v2.
|
|
163
|
+
// Since we already use privileged mode, HOST namespace is acceptable.
|
|
164
|
+
cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
|
|
165
|
+
// Privileged mode required for systemd to manage cgroups and system services
|
|
166
|
+
privileged: Some(true),
|
|
167
|
+
..Default::default()
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// Simple config for tini mode (works on macOS and Linux)
|
|
171
|
+
HostConfig {
|
|
172
|
+
mounts: Some(mounts),
|
|
173
|
+
port_bindings: Some(port_bindings),
|
|
174
|
+
auto_remove: Some(false),
|
|
175
|
+
..Default::default()
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Build environment variables
|
|
180
|
+
// Add USE_SYSTEMD=1 when Cockpit is enabled to tell entrypoint to use systemd
|
|
181
|
+
let final_env = if cockpit_enabled_val {
|
|
182
|
+
let mut env = env_vars.unwrap_or_default();
|
|
183
|
+
env.push("USE_SYSTEMD=1".to_string());
|
|
184
|
+
Some(env)
|
|
185
|
+
} else {
|
|
186
|
+
env_vars
|
|
121
187
|
};
|
|
122
188
|
|
|
123
189
|
// Create container config
|
|
@@ -126,7 +192,7 @@ pub async fn create_container(
|
|
|
126
192
|
hostname: Some(CONTAINER_NAME.to_string()),
|
|
127
193
|
working_dir: Some("/workspace".to_string()),
|
|
128
194
|
exposed_ports: Some(exposed_ports),
|
|
129
|
-
env:
|
|
195
|
+
env: final_env,
|
|
130
196
|
host_config: Some(host_config),
|
|
131
197
|
..Default::default()
|
|
132
198
|
};
|
|
@@ -145,11 +211,10 @@ pub async fn create_container(
|
|
|
145
211
|
let msg = e.to_string();
|
|
146
212
|
if msg.contains("port is already allocated") || msg.contains("address already in use") {
|
|
147
213
|
DockerError::Container(format!(
|
|
148
|
-
"Port {} is already in use. Stop the service using that port or use a different port with --port."
|
|
149
|
-
port
|
|
214
|
+
"Port {port} is already in use. Stop the service using that port or use a different port with --port."
|
|
150
215
|
))
|
|
151
216
|
} else {
|
|
152
|
-
DockerError::Container(format!("Failed to create container: {}"
|
|
217
|
+
DockerError::Container(format!("Failed to create container: {e}"))
|
|
153
218
|
}
|
|
154
219
|
})?;
|
|
155
220
|
|
|
@@ -165,9 +230,7 @@ pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), Do
|
|
|
165
230
|
.inner()
|
|
166
231
|
.start_container(name, None::<StartContainerOptions<String>>)
|
|
167
232
|
.await
|
|
168
|
-
.map_err(|e| {
|
|
169
|
-
DockerError::Container(format!("Failed to start container {}: {}", name, e))
|
|
170
|
-
})?;
|
|
233
|
+
.map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
|
|
171
234
|
|
|
172
235
|
debug!("Container {} started", name);
|
|
173
236
|
Ok(())
|
|
@@ -198,9 +261,9 @@ pub async fn stop_container(
|
|
|
198
261
|
// "container already stopped" is not an error
|
|
199
262
|
if msg.contains("is not running") || msg.contains("304") {
|
|
200
263
|
debug!("Container {} was already stopped", name);
|
|
201
|
-
return DockerError::Container(format!("Container '{}' is not running"
|
|
264
|
+
return DockerError::Container(format!("Container '{name}' is not running"));
|
|
202
265
|
}
|
|
203
|
-
DockerError::Container(format!("Failed to stop container {}: {}"
|
|
266
|
+
DockerError::Container(format!("Failed to stop container {name}: {e}"))
|
|
204
267
|
})?;
|
|
205
268
|
|
|
206
269
|
debug!("Container {} stopped", name);
|
|
@@ -230,9 +293,7 @@ pub async fn remove_container(
|
|
|
230
293
|
.inner()
|
|
231
294
|
.remove_container(name, Some(options))
|
|
232
295
|
.await
|
|
233
|
-
.map_err(|e| {
|
|
234
|
-
DockerError::Container(format!("Failed to remove container {}: {}", name, e))
|
|
235
|
-
})?;
|
|
296
|
+
.map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
|
|
236
297
|
|
|
237
298
|
debug!("Container {} removed", name);
|
|
238
299
|
Ok(())
|
|
@@ -248,8 +309,7 @@ pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool,
|
|
|
248
309
|
status_code: 404, ..
|
|
249
310
|
}) => Ok(false),
|
|
250
311
|
Err(e) => Err(DockerError::Container(format!(
|
|
251
|
-
"Failed to inspect container {}: {}"
|
|
252
|
-
name, e
|
|
312
|
+
"Failed to inspect container {name}: {e}"
|
|
253
313
|
))),
|
|
254
314
|
}
|
|
255
315
|
}
|
|
@@ -267,8 +327,7 @@ pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<b
|
|
|
267
327
|
status_code: 404, ..
|
|
268
328
|
}) => Ok(false),
|
|
269
329
|
Err(e) => Err(DockerError::Container(format!(
|
|
270
|
-
"Failed to inspect container {}: {}"
|
|
271
|
-
name, e
|
|
330
|
+
"Failed to inspect container {name}: {e}"
|
|
272
331
|
))),
|
|
273
332
|
}
|
|
274
333
|
}
|
|
@@ -289,12 +348,10 @@ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String
|
|
|
289
348
|
Err(bollard::errors::Error::DockerResponseServerError {
|
|
290
349
|
status_code: 404, ..
|
|
291
350
|
}) => Err(DockerError::Container(format!(
|
|
292
|
-
"Container '{}' not found"
|
|
293
|
-
name
|
|
351
|
+
"Container '{name}' not found"
|
|
294
352
|
))),
|
|
295
353
|
Err(e) => Err(DockerError::Container(format!(
|
|
296
|
-
"Failed to inspect container {}: {}"
|
|
297
|
-
name, e
|
|
354
|
+
"Failed to inspect container {name}: {e}"
|
|
298
355
|
))),
|
|
299
356
|
}
|
|
300
357
|
}
|
|
@@ -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
|
+
}
|