@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.
@@ -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
+ }