@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,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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/docker/image.rs
CHANGED
|
@@ -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,23 @@
|
|
|
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;
|
|
22
|
+
pub mod mount;
|
|
17
23
|
pub mod progress;
|
|
24
|
+
pub mod state;
|
|
25
|
+
pub mod update;
|
|
26
|
+
pub mod users;
|
|
27
|
+
mod version;
|
|
18
28
|
pub mod volume;
|
|
19
29
|
|
|
20
30
|
// Core types
|
|
@@ -22,24 +32,51 @@ pub use client::DockerClient;
|
|
|
22
32
|
pub use error::DockerError;
|
|
23
33
|
pub use progress::ProgressReporter;
|
|
24
34
|
|
|
35
|
+
// Health check operations
|
|
36
|
+
pub use health::{
|
|
37
|
+
ExtendedHealthResponse, HealthError, HealthResponse, check_health, check_health_extended,
|
|
38
|
+
};
|
|
39
|
+
|
|
25
40
|
// Dockerfile constants
|
|
26
41
|
pub use dockerfile::{DOCKERFILE, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
|
|
27
42
|
|
|
28
43
|
// Image operations
|
|
29
44
|
pub use image::{build_image, image_exists, pull_image};
|
|
30
45
|
|
|
46
|
+
// Update operations
|
|
47
|
+
pub use update::{UpdateResult, has_previous_image, rollback_image, update_image};
|
|
48
|
+
|
|
49
|
+
// Version detection
|
|
50
|
+
pub use version::{VERSION_LABEL, get_cli_version, get_image_version, versions_compatible};
|
|
51
|
+
|
|
52
|
+
// Container exec operations
|
|
53
|
+
pub use exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
|
|
54
|
+
|
|
55
|
+
// User management operations
|
|
56
|
+
pub use users::{
|
|
57
|
+
UserInfo, create_user, delete_user, list_users, lock_user, set_user_password, unlock_user,
|
|
58
|
+
user_exists,
|
|
59
|
+
};
|
|
60
|
+
|
|
31
61
|
// Volume management
|
|
32
62
|
pub use volume::{
|
|
33
63
|
MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS,
|
|
34
64
|
VOLUME_SESSION, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
|
|
35
65
|
};
|
|
36
66
|
|
|
67
|
+
// Bind mount parsing and validation
|
|
68
|
+
pub use mount::{MountError, ParsedMount, check_container_path_warning, validate_mount_path};
|
|
69
|
+
|
|
37
70
|
// Container lifecycle
|
|
38
71
|
pub use container::{
|
|
39
|
-
CONTAINER_NAME,
|
|
40
|
-
|
|
72
|
+
CONTAINER_NAME, ContainerBindMount, ContainerPorts, OPENCODE_WEB_PORT, container_exists,
|
|
73
|
+
container_is_running, container_state, create_container, get_container_bind_mounts,
|
|
74
|
+
get_container_ports, remove_container, start_container, stop_container,
|
|
41
75
|
};
|
|
42
76
|
|
|
77
|
+
// Image state tracking
|
|
78
|
+
pub use state::{ImageState, clear_state, get_state_path, load_state, save_state};
|
|
79
|
+
|
|
43
80
|
/// Full setup: ensure volumes exist, create container if needed, start it
|
|
44
81
|
///
|
|
45
82
|
/// This is the primary entry point for starting the opencode service.
|
|
@@ -49,10 +86,18 @@ pub use container::{
|
|
|
49
86
|
/// * `client` - Docker client
|
|
50
87
|
/// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
|
|
51
88
|
/// * `env_vars` - Additional environment variables (optional)
|
|
89
|
+
/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
|
|
90
|
+
/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
|
|
91
|
+
/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
|
|
92
|
+
/// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
|
|
52
93
|
pub async fn setup_and_start(
|
|
53
94
|
client: &DockerClient,
|
|
54
95
|
opencode_web_port: Option<u16>,
|
|
55
96
|
env_vars: Option<Vec<String>>,
|
|
97
|
+
bind_address: Option<&str>,
|
|
98
|
+
cockpit_port: Option<u16>,
|
|
99
|
+
cockpit_enabled: Option<bool>,
|
|
100
|
+
bind_mounts: Option<Vec<mount::ParsedMount>>,
|
|
56
101
|
) -> Result<String, DockerError> {
|
|
57
102
|
// Ensure volumes exist first
|
|
58
103
|
volume::ensure_volumes_exist(client).await?;
|
|
@@ -65,13 +110,24 @@ pub async fn setup_and_start(
|
|
|
65
110
|
.inspect_container(container::CONTAINER_NAME, None)
|
|
66
111
|
.await
|
|
67
112
|
.map_err(|e| {
|
|
68
|
-
DockerError::Container(format!("Failed to inspect existing container: {}"
|
|
113
|
+
DockerError::Container(format!("Failed to inspect existing container: {e}"))
|
|
69
114
|
})?;
|
|
70
115
|
info.id
|
|
71
116
|
.unwrap_or_else(|| container::CONTAINER_NAME.to_string())
|
|
72
117
|
} else {
|
|
73
118
|
// Create new container
|
|
74
|
-
container::create_container(
|
|
119
|
+
container::create_container(
|
|
120
|
+
client,
|
|
121
|
+
None,
|
|
122
|
+
None,
|
|
123
|
+
opencode_web_port,
|
|
124
|
+
env_vars,
|
|
125
|
+
bind_address,
|
|
126
|
+
cockpit_port,
|
|
127
|
+
cockpit_enabled,
|
|
128
|
+
bind_mounts,
|
|
129
|
+
)
|
|
130
|
+
.await?
|
|
75
131
|
};
|
|
76
132
|
|
|
77
133
|
// Start if not running
|
|
@@ -82,25 +138,33 @@ pub async fn setup_and_start(
|
|
|
82
138
|
Ok(container_id)
|
|
83
139
|
}
|
|
84
140
|
|
|
141
|
+
/// Default graceful shutdown timeout in seconds
|
|
142
|
+
pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
|
|
143
|
+
|
|
85
144
|
/// Stop and optionally remove the opencode container
|
|
86
145
|
///
|
|
87
146
|
/// # Arguments
|
|
88
147
|
/// * `client` - Docker client
|
|
89
148
|
/// * `remove` - Also remove the container after stopping
|
|
90
|
-
|
|
149
|
+
/// * `timeout_secs` - Graceful shutdown timeout (default: 30 seconds)
|
|
150
|
+
pub async fn stop_service(
|
|
151
|
+
client: &DockerClient,
|
|
152
|
+
remove: bool,
|
|
153
|
+
timeout_secs: Option<i64>,
|
|
154
|
+
) -> Result<(), DockerError> {
|
|
91
155
|
let name = container::CONTAINER_NAME;
|
|
156
|
+
let timeout = timeout_secs.unwrap_or(DEFAULT_STOP_TIMEOUT_SECS);
|
|
92
157
|
|
|
93
158
|
// Check if container exists
|
|
94
159
|
if !container::container_exists(client, name).await? {
|
|
95
160
|
return Err(DockerError::Container(format!(
|
|
96
|
-
"Container '{}' does not exist"
|
|
97
|
-
name
|
|
161
|
+
"Container '{name}' does not exist"
|
|
98
162
|
)));
|
|
99
163
|
}
|
|
100
164
|
|
|
101
165
|
// Stop if running
|
|
102
166
|
if container::container_is_running(client, name).await? {
|
|
103
|
-
container::stop_container(client, name,
|
|
167
|
+
container::stop_container(client, name, Some(timeout)).await?;
|
|
104
168
|
}
|
|
105
169
|
|
|
106
170
|
// Remove if requested
|