Skip to main content

opencode_cloud_core/docker/
image.rs

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
6use super::progress::ProgressReporter;
7use super::{
8    DOCKERFILE, DockerClient, DockerError, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT,
9};
10use bollard::image::{BuildImageOptions, CreateImageOptions};
11use bollard::models::BuildInfoAux;
12use bytes::Bytes;
13use flate2::Compression;
14use flate2::write::GzEncoder;
15use futures_util::StreamExt;
16use std::collections::VecDeque;
17use tar::Builder as TarBuilder;
18use tracing::{debug, warn};
19
20/// Maximum number of recent build log lines to capture for error context
21const BUILD_LOG_BUFFER_SIZE: usize = 20;
22
23/// Maximum number of error lines to capture separately
24const ERROR_LOG_BUFFER_SIZE: usize = 10;
25
26/// Check if a line looks like an error message
27fn 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
38pub 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
65pub 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.
173pub 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: {ghcr_err}. Docker Hub: {dockerhub_err}"
207        ))),
208    }
209}
210
211/// Maximum number of retry attempts for pull operations
212const MAX_PULL_RETRIES: usize = 3;
213
214/// Pull from a specific registry with retry logic
215async fn pull_from_registry(
216    client: &DockerClient,
217    image: &str,
218    tag: &str,
219    progress: &mut ProgressReporter,
220) -> Result<(), DockerError> {
221    let full_name = format!("{image}:{tag}");
222
223    // Manual retry loop since async closures can't capture mutable references
224    let mut last_error = None;
225    for attempt in 1..=MAX_PULL_RETRIES {
226        debug!(
227            "Pull attempt {}/{} for {}",
228            attempt, MAX_PULL_RETRIES, full_name
229        );
230
231        match do_pull(client, image, tag, progress).await {
232            Ok(()) => return Ok(()),
233            Err(e) => {
234                warn!("Pull attempt {} failed: {}", attempt, e);
235                last_error = Some(e);
236
237                if attempt < MAX_PULL_RETRIES {
238                    // Exponential backoff: 1s, 2s, 4s
239                    let delay_ms = 1000 * (1 << (attempt - 1));
240                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
241                }
242            }
243        }
244    }
245
246    Err(last_error.unwrap_or_else(|| {
247        DockerError::Pull(format!(
248            "Pull failed for {full_name} after {MAX_PULL_RETRIES} attempts"
249        ))
250    }))
251}
252
253/// Perform the actual pull operation
254async fn do_pull(
255    client: &DockerClient,
256    image: &str,
257    tag: &str,
258    progress: &mut ProgressReporter,
259) -> Result<(), DockerError> {
260    let full_name = format!("{image}:{tag}");
261
262    let options = CreateImageOptions {
263        from_image: image,
264        tag,
265        ..Default::default()
266    };
267
268    let mut stream = client.inner().create_image(Some(options), None, None);
269
270    // Add main spinner for overall progress
271    progress.add_spinner("pull", &format!("Pulling {full_name}..."));
272
273    while let Some(result) = stream.next().await {
274        match result {
275            Ok(info) => {
276                // Handle errors from the stream
277                if let Some(error_msg) = info.error {
278                    progress.abandon_all(&error_msg);
279                    return Err(DockerError::Pull(error_msg));
280                }
281
282                // Handle layer progress
283                if let Some(layer_id) = &info.id {
284                    let status = info.status.as_deref().unwrap_or("");
285
286                    match status {
287                        "Already exists" => {
288                            progress.finish(layer_id, "Already exists");
289                        }
290                        "Pull complete" => {
291                            progress.finish(layer_id, "Pull complete");
292                        }
293                        "Downloading" | "Extracting" => {
294                            if let Some(progress_detail) = &info.progress_detail {
295                                let current = progress_detail.current.unwrap_or(0) as u64;
296                                let total = progress_detail.total.unwrap_or(0) as u64;
297
298                                if total > 0 {
299                                    progress.update_layer(layer_id, current, total, status);
300                                }
301                            }
302                        }
303                        _ => {
304                            // Other statuses (Waiting, Verifying, etc.)
305                            progress.update_spinner(layer_id, status);
306                        }
307                    }
308                } else if let Some(status) = &info.status {
309                    // Overall status messages (no layer id)
310                    progress.update_spinner("pull", status);
311                }
312            }
313            Err(e) => {
314                progress.abandon_all("Pull failed");
315                return Err(DockerError::Pull(format!("Pull failed: {e}")));
316            }
317        }
318    }
319
320    progress.finish("pull", &format!("Pull complete: {full_name}"));
321    Ok(())
322}
323
324/// Format a build error with recent log context for actionable debugging
325fn format_build_error_with_context(
326    error: &str,
327    recent_logs: &VecDeque<String>,
328    error_logs: &VecDeque<String>,
329) -> String {
330    let mut message = String::new();
331
332    // Add main error message
333    message.push_str(error);
334
335    // Add captured error lines if they differ from recent logs
336    // (these are error-like lines that may have scrolled off)
337    if !error_logs.is_empty() {
338        // Check if error_logs contains lines not in recent_logs
339        let recent_set: std::collections::HashSet<_> = recent_logs.iter().collect();
340        let unique_errors: Vec<_> = error_logs
341            .iter()
342            .filter(|line| !recent_set.contains(line))
343            .collect();
344
345        if !unique_errors.is_empty() {
346            message.push_str("\n\nPotential errors detected during build:");
347            for line in unique_errors {
348                message.push_str("\n  ");
349                message.push_str(line);
350            }
351        }
352    }
353
354    // Add recent log context if available
355    if !recent_logs.is_empty() {
356        message.push_str("\n\nRecent build output:");
357        for line in recent_logs {
358            message.push_str("\n  ");
359            message.push_str(line);
360        }
361    }
362
363    // Add actionable suggestions based on common error patterns
364    let error_lower = error.to_lowercase();
365    if error_lower.contains("network")
366        || error_lower.contains("connection")
367        || error_lower.contains("timeout")
368    {
369        message.push_str("\n\nSuggestion: Check your network connection and Docker's ability to reach the internet.");
370    } else if error_lower.contains("disk")
371        || error_lower.contains("space")
372        || error_lower.contains("no space")
373    {
374        message.push_str("\n\nSuggestion: Free up disk space with 'docker system prune' or check available storage.");
375    } else if error_lower.contains("permission") || error_lower.contains("denied") {
376        message.push_str("\n\nSuggestion: Check Docker permissions. You may need to add your user to the 'docker' group.");
377    }
378
379    message
380}
381
382/// Create a gzipped tar archive containing the Dockerfile
383fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
384    let mut archive_buffer = Vec::new();
385
386    {
387        let encoder = GzEncoder::new(&mut archive_buffer, Compression::default());
388        let mut tar = TarBuilder::new(encoder);
389
390        // Add Dockerfile to archive
391        let dockerfile_bytes = DOCKERFILE.as_bytes();
392        let mut header = tar::Header::new_gnu();
393        header.set_path("Dockerfile")?;
394        header.set_size(dockerfile_bytes.len() as u64);
395        header.set_mode(0o644);
396        header.set_cksum();
397
398        tar.append(&header, dockerfile_bytes)?;
399        tar.finish()?;
400
401        // Finish gzip encoding
402        let encoder = tar.into_inner()?;
403        encoder.finish()?;
404    }
405
406    Ok(archive_buffer)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn create_build_context_succeeds() {
415        let context = create_build_context().expect("should create context");
416        assert!(!context.is_empty(), "context should not be empty");
417
418        // Verify it's gzip-compressed (gzip magic bytes)
419        assert_eq!(context[0], 0x1f, "should be gzip compressed");
420        assert_eq!(context[1], 0x8b, "should be gzip compressed");
421    }
422
423    #[test]
424    fn default_tag_is_latest() {
425        assert_eq!(IMAGE_TAG_DEFAULT, "latest");
426    }
427
428    #[test]
429    fn format_build_error_includes_recent_logs() {
430        let mut logs = VecDeque::new();
431        logs.push_back("Step 1/5 : FROM ubuntu:22.04".to_string());
432        logs.push_back("Step 2/5 : RUN apt-get update".to_string());
433        logs.push_back("E: Unable to fetch some archives".to_string());
434        let error_logs = VecDeque::new();
435
436        let result =
437            format_build_error_with_context("Build failed: exit code 1", &logs, &error_logs);
438
439        assert!(result.contains("Build failed: exit code 1"));
440        assert!(result.contains("Recent build output:"));
441        assert!(result.contains("Step 1/5"));
442        assert!(result.contains("Unable to fetch"));
443    }
444
445    #[test]
446    fn format_build_error_handles_empty_logs() {
447        let logs = VecDeque::new();
448        let error_logs = VecDeque::new();
449        let result = format_build_error_with_context("Stream error", &logs, &error_logs);
450
451        assert!(result.contains("Stream error"));
452        assert!(!result.contains("Recent build output:"));
453    }
454
455    #[test]
456    fn format_build_error_adds_network_suggestion() {
457        let logs = VecDeque::new();
458        let error_logs = VecDeque::new();
459        let result = format_build_error_with_context("connection timeout", &logs, &error_logs);
460
461        assert!(result.contains("Check your network connection"));
462    }
463
464    #[test]
465    fn format_build_error_adds_disk_suggestion() {
466        let logs = VecDeque::new();
467        let error_logs = VecDeque::new();
468        let result = format_build_error_with_context("no space left on device", &logs, &error_logs);
469
470        assert!(result.contains("Free up disk space"));
471    }
472
473    #[test]
474    fn format_build_error_shows_error_lines_separately() {
475        let mut recent_logs = VecDeque::new();
476        recent_logs.push_back("Compiling foo v1.0".to_string());
477        recent_logs.push_back("Successfully installed bar".to_string());
478
479        let mut error_logs = VecDeque::new();
480        error_logs.push_back("error: failed to compile dust".to_string());
481        error_logs.push_back("error: failed to compile glow".to_string());
482
483        let result = format_build_error_with_context("Build failed", &recent_logs, &error_logs);
484
485        assert!(result.contains("Potential errors detected during build:"));
486        assert!(result.contains("failed to compile dust"));
487        assert!(result.contains("failed to compile glow"));
488    }
489
490    #[test]
491    fn is_error_line_detects_errors() {
492        assert!(is_error_line("error: something failed"));
493        assert!(is_error_line("Error: build failed"));
494        assert!(is_error_line("Failed to install package"));
495        assert!(is_error_line("cannot find module"));
496        assert!(is_error_line("Unable to locate package"));
497        assert!(!is_error_line("Compiling foo v1.0"));
498        assert!(!is_error_line("Successfully installed"));
499    }
500}