@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,502 @@
|
|
|
1
|
+
//! Docker image build and pull operations
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides functionality to build Docker images from the embedded
|
|
4
|
+
//! Dockerfile and pull images from registries with progress feedback.
|
|
5
|
+
|
|
6
|
+
use super::progress::ProgressReporter;
|
|
7
|
+
use super::{
|
|
8
|
+
DOCKERFILE, DockerClient, DockerError, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT,
|
|
9
|
+
};
|
|
10
|
+
use bollard::image::{BuildImageOptions, CreateImageOptions};
|
|
11
|
+
use bollard::models::BuildInfoAux;
|
|
12
|
+
use bytes::Bytes;
|
|
13
|
+
use flate2::Compression;
|
|
14
|
+
use flate2::write::GzEncoder;
|
|
15
|
+
use futures_util::StreamExt;
|
|
16
|
+
use std::collections::VecDeque;
|
|
17
|
+
use tar::Builder as TarBuilder;
|
|
18
|
+
use tracing::{debug, warn};
|
|
19
|
+
|
|
20
|
+
/// Maximum number of recent build log lines to capture for error context
|
|
21
|
+
const BUILD_LOG_BUFFER_SIZE: usize = 20;
|
|
22
|
+
|
|
23
|
+
/// Maximum number of error lines to capture separately
|
|
24
|
+
const ERROR_LOG_BUFFER_SIZE: usize = 10;
|
|
25
|
+
|
|
26
|
+
/// Check if a line looks like an error message
|
|
27
|
+
fn is_error_line(line: &str) -> bool {
|
|
28
|
+
let lower = line.to_lowercase();
|
|
29
|
+
lower.contains("error")
|
|
30
|
+
|| lower.contains("failed")
|
|
31
|
+
|| lower.contains("cannot")
|
|
32
|
+
|| lower.contains("unable to")
|
|
33
|
+
|| lower.contains("not found")
|
|
34
|
+
|| lower.contains("permission denied")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Check if an image exists locally
|
|
38
|
+
pub async fn image_exists(
|
|
39
|
+
client: &DockerClient,
|
|
40
|
+
image: &str,
|
|
41
|
+
tag: &str,
|
|
42
|
+
) -> Result<bool, DockerError> {
|
|
43
|
+
let full_name = format!("{image}:{tag}");
|
|
44
|
+
debug!("Checking if image exists: {}", full_name);
|
|
45
|
+
|
|
46
|
+
match client.inner().inspect_image(&full_name).await {
|
|
47
|
+
Ok(_) => Ok(true),
|
|
48
|
+
Err(bollard::errors::Error::DockerResponseServerError {
|
|
49
|
+
status_code: 404, ..
|
|
50
|
+
}) => Ok(false),
|
|
51
|
+
Err(e) => Err(DockerError::from(e)),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Build the opencode image from embedded Dockerfile
|
|
56
|
+
///
|
|
57
|
+
/// Shows real-time build progress with streaming output.
|
|
58
|
+
/// Returns the full image:tag string on success.
|
|
59
|
+
///
|
|
60
|
+
/// # Arguments
|
|
61
|
+
/// * `client` - Docker client
|
|
62
|
+
/// * `tag` - Image tag (defaults to IMAGE_TAG_DEFAULT)
|
|
63
|
+
/// * `progress` - Progress reporter for build feedback
|
|
64
|
+
/// * `no_cache` - If true, build without using Docker layer cache
|
|
65
|
+
pub async fn build_image(
|
|
66
|
+
client: &DockerClient,
|
|
67
|
+
tag: Option<&str>,
|
|
68
|
+
progress: &mut ProgressReporter,
|
|
69
|
+
no_cache: bool,
|
|
70
|
+
) -> Result<String, DockerError> {
|
|
71
|
+
let tag = tag.unwrap_or(IMAGE_TAG_DEFAULT);
|
|
72
|
+
let full_name = format!("{IMAGE_NAME_GHCR}:{tag}");
|
|
73
|
+
debug!("Building image: {} (no_cache: {})", full_name, no_cache);
|
|
74
|
+
|
|
75
|
+
// Create tar archive containing Dockerfile
|
|
76
|
+
let context = create_build_context()
|
|
77
|
+
.map_err(|e| DockerError::Build(format!("Failed to create build context: {e}")))?;
|
|
78
|
+
|
|
79
|
+
// Set up build options
|
|
80
|
+
let options = BuildImageOptions {
|
|
81
|
+
t: full_name.clone(),
|
|
82
|
+
dockerfile: "Dockerfile".to_string(),
|
|
83
|
+
rm: true,
|
|
84
|
+
nocache: no_cache,
|
|
85
|
+
..Default::default()
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Create build body from context
|
|
89
|
+
let body = Bytes::from(context);
|
|
90
|
+
|
|
91
|
+
// Start build with streaming output
|
|
92
|
+
let mut stream = client.inner().build_image(options, None, Some(body));
|
|
93
|
+
|
|
94
|
+
// Add main build spinner (context prefix like "Building image" is set by caller)
|
|
95
|
+
progress.add_spinner("build", "Initializing...");
|
|
96
|
+
|
|
97
|
+
let mut maybe_image_id = None;
|
|
98
|
+
let mut recent_logs: VecDeque<String> = VecDeque::with_capacity(BUILD_LOG_BUFFER_SIZE);
|
|
99
|
+
let mut error_logs: VecDeque<String> = VecDeque::with_capacity(ERROR_LOG_BUFFER_SIZE);
|
|
100
|
+
|
|
101
|
+
while let Some(result) = stream.next().await {
|
|
102
|
+
match result {
|
|
103
|
+
Ok(info) => {
|
|
104
|
+
// Handle stream output (build log messages)
|
|
105
|
+
if let Some(stream_msg) = info.stream {
|
|
106
|
+
let msg = stream_msg.trim();
|
|
107
|
+
if !msg.is_empty() {
|
|
108
|
+
progress.update_spinner("build", msg);
|
|
109
|
+
|
|
110
|
+
// Capture recent log lines for error context
|
|
111
|
+
if recent_logs.len() >= BUILD_LOG_BUFFER_SIZE {
|
|
112
|
+
recent_logs.pop_front();
|
|
113
|
+
}
|
|
114
|
+
recent_logs.push_back(msg.to_string());
|
|
115
|
+
|
|
116
|
+
// Also capture error-like lines separately (they might scroll off)
|
|
117
|
+
if is_error_line(msg) {
|
|
118
|
+
if error_logs.len() >= ERROR_LOG_BUFFER_SIZE {
|
|
119
|
+
error_logs.pop_front();
|
|
120
|
+
}
|
|
121
|
+
error_logs.push_back(msg.to_string());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Capture step information for better progress
|
|
125
|
+
if msg.starts_with("Step ") {
|
|
126
|
+
debug!("Build step: {}", msg);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Handle error messages
|
|
132
|
+
if let Some(error_msg) = info.error {
|
|
133
|
+
progress.abandon_all(&error_msg);
|
|
134
|
+
let context =
|
|
135
|
+
format_build_error_with_context(&error_msg, &recent_logs, &error_logs);
|
|
136
|
+
return Err(DockerError::Build(context));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Capture the image ID from aux field
|
|
140
|
+
if let Some(aux) = info.aux {
|
|
141
|
+
match aux {
|
|
142
|
+
BuildInfoAux::Default(image_id) => {
|
|
143
|
+
if let Some(id) = image_id.id {
|
|
144
|
+
maybe_image_id = Some(id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
BuildInfoAux::BuildKit(_) => {
|
|
148
|
+
// BuildKit responses are handled via stream messages
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
Err(e) => {
|
|
154
|
+
progress.abandon_all("Build failed");
|
|
155
|
+
let context =
|
|
156
|
+
format_build_error_with_context(&e.to_string(), &recent_logs, &error_logs);
|
|
157
|
+
return Err(DockerError::Build(context));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let image_id = maybe_image_id.unwrap_or_else(|| "unknown".to_string());
|
|
163
|
+
let finish_msg = format!("Build complete: {image_id}");
|
|
164
|
+
progress.finish("build", &finish_msg);
|
|
165
|
+
|
|
166
|
+
Ok(full_name)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Pull the opencode image from registry with automatic fallback
|
|
170
|
+
///
|
|
171
|
+
/// Tries GHCR first, falls back to Docker Hub on failure.
|
|
172
|
+
/// Returns the full image:tag string on success.
|
|
173
|
+
pub async fn pull_image(
|
|
174
|
+
client: &DockerClient,
|
|
175
|
+
tag: Option<&str>,
|
|
176
|
+
progress: &mut ProgressReporter,
|
|
177
|
+
) -> Result<String, DockerError> {
|
|
178
|
+
let tag = tag.unwrap_or(IMAGE_TAG_DEFAULT);
|
|
179
|
+
|
|
180
|
+
// Try GHCR first
|
|
181
|
+
debug!("Attempting to pull from GHCR: {}:{}", IMAGE_NAME_GHCR, tag);
|
|
182
|
+
let ghcr_err = match pull_from_registry(client, IMAGE_NAME_GHCR, tag, progress).await {
|
|
183
|
+
Ok(()) => {
|
|
184
|
+
let full_name = format!("{IMAGE_NAME_GHCR}:{tag}");
|
|
185
|
+
return Ok(full_name);
|
|
186
|
+
}
|
|
187
|
+
Err(e) => e,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
warn!(
|
|
191
|
+
"GHCR pull failed: {}. Trying Docker Hub fallback...",
|
|
192
|
+
ghcr_err
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Try Docker Hub as fallback
|
|
196
|
+
debug!(
|
|
197
|
+
"Attempting to pull from Docker Hub: {}:{}",
|
|
198
|
+
IMAGE_NAME_DOCKERHUB, tag
|
|
199
|
+
);
|
|
200
|
+
match pull_from_registry(client, IMAGE_NAME_DOCKERHUB, tag, progress).await {
|
|
201
|
+
Ok(()) => {
|
|
202
|
+
let full_name = format!("{IMAGE_NAME_DOCKERHUB}:{tag}");
|
|
203
|
+
Ok(full_name)
|
|
204
|
+
}
|
|
205
|
+
Err(dockerhub_err) => Err(DockerError::Pull(format!(
|
|
206
|
+
"Failed to pull from both registries. GHCR: {}. Docker Hub: {}",
|
|
207
|
+
ghcr_err, dockerhub_err
|
|
208
|
+
))),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Maximum number of retry attempts for pull operations
|
|
213
|
+
const MAX_PULL_RETRIES: usize = 3;
|
|
214
|
+
|
|
215
|
+
/// Pull from a specific registry with retry logic
|
|
216
|
+
async fn pull_from_registry(
|
|
217
|
+
client: &DockerClient,
|
|
218
|
+
image: &str,
|
|
219
|
+
tag: &str,
|
|
220
|
+
progress: &mut ProgressReporter,
|
|
221
|
+
) -> Result<(), DockerError> {
|
|
222
|
+
let full_name = format!("{image}:{tag}");
|
|
223
|
+
|
|
224
|
+
// Manual retry loop since async closures can't capture mutable references
|
|
225
|
+
let mut last_error = None;
|
|
226
|
+
for attempt in 1..=MAX_PULL_RETRIES {
|
|
227
|
+
debug!(
|
|
228
|
+
"Pull attempt {}/{} for {}",
|
|
229
|
+
attempt, MAX_PULL_RETRIES, full_name
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
match do_pull(client, image, tag, progress).await {
|
|
233
|
+
Ok(()) => return Ok(()),
|
|
234
|
+
Err(e) => {
|
|
235
|
+
warn!("Pull attempt {} failed: {}", attempt, e);
|
|
236
|
+
last_error = Some(e);
|
|
237
|
+
|
|
238
|
+
if attempt < MAX_PULL_RETRIES {
|
|
239
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
240
|
+
let delay_ms = 1000 * (1 << (attempt - 1));
|
|
241
|
+
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
Err(last_error.unwrap_or_else(|| {
|
|
248
|
+
DockerError::Pull(format!(
|
|
249
|
+
"Pull failed for {} after {} attempts",
|
|
250
|
+
full_name, MAX_PULL_RETRIES
|
|
251
|
+
))
|
|
252
|
+
}))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// Perform the actual pull operation
|
|
256
|
+
async fn do_pull(
|
|
257
|
+
client: &DockerClient,
|
|
258
|
+
image: &str,
|
|
259
|
+
tag: &str,
|
|
260
|
+
progress: &mut ProgressReporter,
|
|
261
|
+
) -> Result<(), DockerError> {
|
|
262
|
+
let full_name = format!("{image}:{tag}");
|
|
263
|
+
|
|
264
|
+
let options = CreateImageOptions {
|
|
265
|
+
from_image: image,
|
|
266
|
+
tag,
|
|
267
|
+
..Default::default()
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
let mut stream = client.inner().create_image(Some(options), None, None);
|
|
271
|
+
|
|
272
|
+
// Add main spinner for overall progress
|
|
273
|
+
progress.add_spinner("pull", &format!("Pulling {full_name}..."));
|
|
274
|
+
|
|
275
|
+
while let Some(result) = stream.next().await {
|
|
276
|
+
match result {
|
|
277
|
+
Ok(info) => {
|
|
278
|
+
// Handle errors from the stream
|
|
279
|
+
if let Some(error_msg) = info.error {
|
|
280
|
+
progress.abandon_all(&error_msg);
|
|
281
|
+
return Err(DockerError::Pull(error_msg));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Handle layer progress
|
|
285
|
+
if let Some(layer_id) = &info.id {
|
|
286
|
+
let status = info.status.as_deref().unwrap_or("");
|
|
287
|
+
|
|
288
|
+
match status {
|
|
289
|
+
"Already exists" => {
|
|
290
|
+
progress.finish(layer_id, "Already exists");
|
|
291
|
+
}
|
|
292
|
+
"Pull complete" => {
|
|
293
|
+
progress.finish(layer_id, "Pull complete");
|
|
294
|
+
}
|
|
295
|
+
"Downloading" | "Extracting" => {
|
|
296
|
+
if let Some(progress_detail) = &info.progress_detail {
|
|
297
|
+
let current = progress_detail.current.unwrap_or(0) as u64;
|
|
298
|
+
let total = progress_detail.total.unwrap_or(0) as u64;
|
|
299
|
+
|
|
300
|
+
if total > 0 {
|
|
301
|
+
progress.update_layer(layer_id, current, total, status);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
_ => {
|
|
306
|
+
// Other statuses (Waiting, Verifying, etc.)
|
|
307
|
+
progress.update_spinner(layer_id, status);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} else if let Some(status) = &info.status {
|
|
311
|
+
// Overall status messages (no layer id)
|
|
312
|
+
progress.update_spinner("pull", status);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
Err(e) => {
|
|
316
|
+
progress.abandon_all("Pull failed");
|
|
317
|
+
return Err(DockerError::Pull(format!("Pull failed: {e}")));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
progress.finish("pull", &format!("Pull complete: {full_name}"));
|
|
323
|
+
Ok(())
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/// Format a build error with recent log context for actionable debugging
|
|
327
|
+
fn format_build_error_with_context(
|
|
328
|
+
error: &str,
|
|
329
|
+
recent_logs: &VecDeque<String>,
|
|
330
|
+
error_logs: &VecDeque<String>,
|
|
331
|
+
) -> String {
|
|
332
|
+
let mut message = String::new();
|
|
333
|
+
|
|
334
|
+
// Add main error message
|
|
335
|
+
message.push_str(error);
|
|
336
|
+
|
|
337
|
+
// Add captured error lines if they differ from recent logs
|
|
338
|
+
// (these are error-like lines that may have scrolled off)
|
|
339
|
+
if !error_logs.is_empty() {
|
|
340
|
+
// Check if error_logs contains lines not in recent_logs
|
|
341
|
+
let recent_set: std::collections::HashSet<_> = recent_logs.iter().collect();
|
|
342
|
+
let unique_errors: Vec<_> = error_logs
|
|
343
|
+
.iter()
|
|
344
|
+
.filter(|line| !recent_set.contains(line))
|
|
345
|
+
.collect();
|
|
346
|
+
|
|
347
|
+
if !unique_errors.is_empty() {
|
|
348
|
+
message.push_str("\n\nPotential errors detected during build:");
|
|
349
|
+
for line in unique_errors {
|
|
350
|
+
message.push_str("\n ");
|
|
351
|
+
message.push_str(line);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Add recent log context if available
|
|
357
|
+
if !recent_logs.is_empty() {
|
|
358
|
+
message.push_str("\n\nRecent build output:");
|
|
359
|
+
for line in recent_logs {
|
|
360
|
+
message.push_str("\n ");
|
|
361
|
+
message.push_str(line);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Add actionable suggestions based on common error patterns
|
|
366
|
+
let error_lower = error.to_lowercase();
|
|
367
|
+
if error_lower.contains("network")
|
|
368
|
+
|| error_lower.contains("connection")
|
|
369
|
+
|| error_lower.contains("timeout")
|
|
370
|
+
{
|
|
371
|
+
message.push_str("\n\nSuggestion: Check your network connection and Docker's ability to reach the internet.");
|
|
372
|
+
} else if error_lower.contains("disk")
|
|
373
|
+
|| error_lower.contains("space")
|
|
374
|
+
|| error_lower.contains("no space")
|
|
375
|
+
{
|
|
376
|
+
message.push_str("\n\nSuggestion: Free up disk space with 'docker system prune' or check available storage.");
|
|
377
|
+
} else if error_lower.contains("permission") || error_lower.contains("denied") {
|
|
378
|
+
message.push_str("\n\nSuggestion: Check Docker permissions. You may need to add your user to the 'docker' group.");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
message
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// Create a gzipped tar archive containing the Dockerfile
|
|
385
|
+
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|
386
|
+
let mut archive_buffer = Vec::new();
|
|
387
|
+
|
|
388
|
+
{
|
|
389
|
+
let encoder = GzEncoder::new(&mut archive_buffer, Compression::default());
|
|
390
|
+
let mut tar = TarBuilder::new(encoder);
|
|
391
|
+
|
|
392
|
+
// Add Dockerfile to archive
|
|
393
|
+
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
|
394
|
+
let mut header = tar::Header::new_gnu();
|
|
395
|
+
header.set_path("Dockerfile")?;
|
|
396
|
+
header.set_size(dockerfile_bytes.len() as u64);
|
|
397
|
+
header.set_mode(0o644);
|
|
398
|
+
header.set_cksum();
|
|
399
|
+
|
|
400
|
+
tar.append(&header, dockerfile_bytes)?;
|
|
401
|
+
tar.finish()?;
|
|
402
|
+
|
|
403
|
+
// Finish gzip encoding
|
|
404
|
+
let encoder = tar.into_inner()?;
|
|
405
|
+
encoder.finish()?;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
Ok(archive_buffer)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#[cfg(test)]
|
|
412
|
+
mod tests {
|
|
413
|
+
use super::*;
|
|
414
|
+
|
|
415
|
+
#[test]
|
|
416
|
+
fn create_build_context_succeeds() {
|
|
417
|
+
let context = create_build_context().expect("should create context");
|
|
418
|
+
assert!(!context.is_empty(), "context should not be empty");
|
|
419
|
+
|
|
420
|
+
// Verify it's gzip-compressed (gzip magic bytes)
|
|
421
|
+
assert_eq!(context[0], 0x1f, "should be gzip compressed");
|
|
422
|
+
assert_eq!(context[1], 0x8b, "should be gzip compressed");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#[test]
|
|
426
|
+
fn default_tag_is_latest() {
|
|
427
|
+
assert_eq!(IMAGE_TAG_DEFAULT, "latest");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#[test]
|
|
431
|
+
fn format_build_error_includes_recent_logs() {
|
|
432
|
+
let mut logs = VecDeque::new();
|
|
433
|
+
logs.push_back("Step 1/5 : FROM ubuntu:22.04".to_string());
|
|
434
|
+
logs.push_back("Step 2/5 : RUN apt-get update".to_string());
|
|
435
|
+
logs.push_back("E: Unable to fetch some archives".to_string());
|
|
436
|
+
let error_logs = VecDeque::new();
|
|
437
|
+
|
|
438
|
+
let result =
|
|
439
|
+
format_build_error_with_context("Build failed: exit code 1", &logs, &error_logs);
|
|
440
|
+
|
|
441
|
+
assert!(result.contains("Build failed: exit code 1"));
|
|
442
|
+
assert!(result.contains("Recent build output:"));
|
|
443
|
+
assert!(result.contains("Step 1/5"));
|
|
444
|
+
assert!(result.contains("Unable to fetch"));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#[test]
|
|
448
|
+
fn format_build_error_handles_empty_logs() {
|
|
449
|
+
let logs = VecDeque::new();
|
|
450
|
+
let error_logs = VecDeque::new();
|
|
451
|
+
let result = format_build_error_with_context("Stream error", &logs, &error_logs);
|
|
452
|
+
|
|
453
|
+
assert!(result.contains("Stream error"));
|
|
454
|
+
assert!(!result.contains("Recent build output:"));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[test]
|
|
458
|
+
fn format_build_error_adds_network_suggestion() {
|
|
459
|
+
let logs = VecDeque::new();
|
|
460
|
+
let error_logs = VecDeque::new();
|
|
461
|
+
let result = format_build_error_with_context("connection timeout", &logs, &error_logs);
|
|
462
|
+
|
|
463
|
+
assert!(result.contains("Check your network connection"));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#[test]
|
|
467
|
+
fn format_build_error_adds_disk_suggestion() {
|
|
468
|
+
let logs = VecDeque::new();
|
|
469
|
+
let error_logs = VecDeque::new();
|
|
470
|
+
let result = format_build_error_with_context("no space left on device", &logs, &error_logs);
|
|
471
|
+
|
|
472
|
+
assert!(result.contains("Free up disk space"));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#[test]
|
|
476
|
+
fn format_build_error_shows_error_lines_separately() {
|
|
477
|
+
let mut recent_logs = VecDeque::new();
|
|
478
|
+
recent_logs.push_back("Compiling foo v1.0".to_string());
|
|
479
|
+
recent_logs.push_back("Successfully installed bar".to_string());
|
|
480
|
+
|
|
481
|
+
let mut error_logs = VecDeque::new();
|
|
482
|
+
error_logs.push_back("error: failed to compile dust".to_string());
|
|
483
|
+
error_logs.push_back("error: failed to compile glow".to_string());
|
|
484
|
+
|
|
485
|
+
let result = format_build_error_with_context("Build failed", &recent_logs, &error_logs);
|
|
486
|
+
|
|
487
|
+
assert!(result.contains("Potential errors detected during build:"));
|
|
488
|
+
assert!(result.contains("failed to compile dust"));
|
|
489
|
+
assert!(result.contains("failed to compile glow"));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#[test]
|
|
493
|
+
fn is_error_line_detects_errors() {
|
|
494
|
+
assert!(is_error_line("error: something failed"));
|
|
495
|
+
assert!(is_error_line("Error: build failed"));
|
|
496
|
+
assert!(is_error_line("Failed to install package"));
|
|
497
|
+
assert!(is_error_line("cannot find module"));
|
|
498
|
+
assert!(is_error_line("Unable to locate package"));
|
|
499
|
+
assert!(!is_error_line("Compiling foo v1.0"));
|
|
500
|
+
assert!(!is_error_line("Successfully installed"));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//! Docker operations module
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides Docker container management functionality including:
|
|
4
|
+
//! - Docker client wrapper with connection handling
|
|
5
|
+
//! - Docker-specific error types
|
|
6
|
+
//! - Embedded Dockerfile for building the opencode image
|
|
7
|
+
//! - Progress reporting for build and pull operations
|
|
8
|
+
//! - Image build and pull operations
|
|
9
|
+
//! - Volume management for persistent storage
|
|
10
|
+
//! - Container lifecycle (create, start, stop, remove)
|
|
11
|
+
|
|
12
|
+
mod client;
|
|
13
|
+
pub mod container;
|
|
14
|
+
mod dockerfile;
|
|
15
|
+
mod error;
|
|
16
|
+
pub mod image;
|
|
17
|
+
pub mod progress;
|
|
18
|
+
pub mod volume;
|
|
19
|
+
|
|
20
|
+
// Core types
|
|
21
|
+
pub use client::DockerClient;
|
|
22
|
+
pub use error::DockerError;
|
|
23
|
+
pub use progress::ProgressReporter;
|
|
24
|
+
|
|
25
|
+
// Dockerfile constants
|
|
26
|
+
pub use dockerfile::{DOCKERFILE, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
|
|
27
|
+
|
|
28
|
+
// Image operations
|
|
29
|
+
pub use image::{build_image, image_exists, pull_image};
|
|
30
|
+
|
|
31
|
+
// Volume management
|
|
32
|
+
pub use volume::{
|
|
33
|
+
MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS,
|
|
34
|
+
VOLUME_SESSION, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Container lifecycle
|
|
38
|
+
pub use container::{
|
|
39
|
+
CONTAINER_NAME, OPENCODE_WEB_PORT, container_exists, container_is_running, container_state,
|
|
40
|
+
create_container, remove_container, start_container, stop_container,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/// Full setup: ensure volumes exist, create container if needed, start it
|
|
44
|
+
///
|
|
45
|
+
/// This is the primary entry point for starting the opencode service.
|
|
46
|
+
/// Returns the container ID on success.
|
|
47
|
+
///
|
|
48
|
+
/// # Arguments
|
|
49
|
+
/// * `client` - Docker client
|
|
50
|
+
/// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
|
|
51
|
+
/// * `env_vars` - Additional environment variables (optional)
|
|
52
|
+
pub async fn setup_and_start(
|
|
53
|
+
client: &DockerClient,
|
|
54
|
+
opencode_web_port: Option<u16>,
|
|
55
|
+
env_vars: Option<Vec<String>>,
|
|
56
|
+
) -> Result<String, DockerError> {
|
|
57
|
+
// Ensure volumes exist first
|
|
58
|
+
volume::ensure_volumes_exist(client).await?;
|
|
59
|
+
|
|
60
|
+
// Check if container already exists
|
|
61
|
+
let container_id = if container::container_exists(client, container::CONTAINER_NAME).await? {
|
|
62
|
+
// Get existing container ID
|
|
63
|
+
let info = client
|
|
64
|
+
.inner()
|
|
65
|
+
.inspect_container(container::CONTAINER_NAME, None)
|
|
66
|
+
.await
|
|
67
|
+
.map_err(|e| {
|
|
68
|
+
DockerError::Container(format!("Failed to inspect existing container: {}", e))
|
|
69
|
+
})?;
|
|
70
|
+
info.id
|
|
71
|
+
.unwrap_or_else(|| container::CONTAINER_NAME.to_string())
|
|
72
|
+
} else {
|
|
73
|
+
// Create new container
|
|
74
|
+
container::create_container(client, None, None, opencode_web_port, env_vars).await?
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Start if not running
|
|
78
|
+
if !container::container_is_running(client, container::CONTAINER_NAME).await? {
|
|
79
|
+
container::start_container(client, container::CONTAINER_NAME).await?;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Ok(container_id)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Stop and optionally remove the opencode container
|
|
86
|
+
///
|
|
87
|
+
/// # Arguments
|
|
88
|
+
/// * `client` - Docker client
|
|
89
|
+
/// * `remove` - Also remove the container after stopping
|
|
90
|
+
pub async fn stop_service(client: &DockerClient, remove: bool) -> Result<(), DockerError> {
|
|
91
|
+
let name = container::CONTAINER_NAME;
|
|
92
|
+
|
|
93
|
+
// Check if container exists
|
|
94
|
+
if !container::container_exists(client, name).await? {
|
|
95
|
+
return Err(DockerError::Container(format!(
|
|
96
|
+
"Container '{}' does not exist",
|
|
97
|
+
name
|
|
98
|
+
)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Stop if running
|
|
102
|
+
if container::container_is_running(client, name).await? {
|
|
103
|
+
container::stop_container(client, name, None).await?;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Remove if requested
|
|
107
|
+
if remove {
|
|
108
|
+
container::remove_container(client, name, false).await?;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Ok(())
|
|
112
|
+
}
|