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: {}. Docker Hub: {}",
207            ghcr_err, dockerhub_err
208        ))),
209    }
210}
211
212/// Maximum number of retry attempts for pull operations
213const MAX_PULL_RETRIES: usize = 3;
214
215/// Pull from a specific registry with retry logic
216async 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
256async 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
327fn 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
385fn 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)]
412mod 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}