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::moby::buildkit::v1::StatusResponse as BuildkitStatusResponse;
11use bollard::models::BuildInfoAux;
12use bollard::query_parameters::{
13    BuildImageOptions, BuilderVersion, CreateImageOptions, ListImagesOptionsBuilder,
14    RemoveImageOptionsBuilder,
15};
16use bytes::Bytes;
17use flate2::Compression;
18use flate2::write::GzEncoder;
19use futures_util::StreamExt;
20use http_body_util::{Either, Full};
21use std::collections::{HashMap, HashSet, VecDeque};
22use std::env;
23use std::time::{SystemTime, UNIX_EPOCH};
24use tar::Builder as TarBuilder;
25use tracing::{debug, warn};
26
27/// Default number of recent build log lines to capture for error context
28const DEFAULT_BUILD_LOG_BUFFER_SIZE: usize = 20;
29
30/// Default number of error lines to capture separately
31const DEFAULT_ERROR_LOG_BUFFER_SIZE: usize = 10;
32
33/// Read a log buffer size from env with bounds
34fn read_log_buffer_size(var_name: &str, default: usize) -> usize {
35    let Ok(value) = env::var(var_name) else {
36        return default;
37    };
38    let Ok(parsed) = value.trim().parse::<usize>() else {
39        return default;
40    };
41    parsed.clamp(5, 500)
42}
43
44/// Check if a line looks like an error message
45fn is_error_line(line: &str) -> bool {
46    let lower = line.to_lowercase();
47    lower.contains("error")
48        || lower.contains("failed")
49        || lower.contains("cannot")
50        || lower.contains("unable to")
51        || lower.contains("not found")
52        || lower.contains("permission denied")
53}
54
55/// Check if an image exists locally
56pub async fn image_exists(
57    client: &DockerClient,
58    image: &str,
59    tag: &str,
60) -> Result<bool, DockerError> {
61    let full_name = format!("{image}:{tag}");
62    debug!("Checking if image exists: {}", full_name);
63
64    match client.inner().inspect_image(&full_name).await {
65        Ok(_) => Ok(true),
66        Err(bollard::errors::Error::DockerResponseServerError {
67            status_code: 404, ..
68        }) => Ok(false),
69        Err(e) => Err(DockerError::from(e)),
70    }
71}
72
73/// Remove all images whose tags, digests, or labels match the provided name fragment
74///
75/// Returns the number of images removed.
76pub async fn remove_images_by_name(
77    client: &DockerClient,
78    name_fragment: &str,
79    force: bool,
80) -> Result<usize, DockerError> {
81    debug!("Removing Docker images matching '{name_fragment}'");
82
83    let images = list_docker_images(client).await?;
84
85    let image_ids = collect_image_ids(&images, name_fragment);
86    remove_image_ids(client, image_ids, force).await
87}
88
89/// List all local Docker images (including intermediate layers).
90async fn list_docker_images(
91    client: &DockerClient,
92) -> Result<Vec<bollard::models::ImageSummary>, DockerError> {
93    let list_options = ListImagesOptionsBuilder::new().all(true).build();
94    client
95        .inner()
96        .list_images(Some(list_options))
97        .await
98        .map_err(|e| DockerError::Image(format!("Failed to list images: {e}")))
99}
100
101const LABEL_TITLE: &str = "org.opencontainers.image.title";
102const LABEL_SOURCE: &str = "org.opencontainers.image.source";
103const LABEL_URL: &str = "org.opencontainers.image.url";
104
105const LABEL_TITLE_VALUE: &str = "opencode-cloud-sandbox";
106const LABEL_SOURCE_VALUE: &str = "https://github.com/pRizz/opencode-cloud";
107const LABEL_URL_VALUE: &str = "https://github.com/pRizz/opencode-cloud";
108
109/// Collect image IDs that contain the provided name fragment or match opencode labels.
110fn collect_image_ids(
111    images: &[bollard::models::ImageSummary],
112    name_fragment: &str,
113) -> HashSet<String> {
114    let mut image_ids = HashSet::new();
115    for image in images {
116        if image_matches_fragment_or_labels(image, name_fragment) {
117            image_ids.insert(image.id.clone());
118        }
119    }
120    image_ids
121}
122
123fn image_matches_fragment_or_labels(
124    image: &bollard::models::ImageSummary,
125    name_fragment: &str,
126) -> bool {
127    let tag_match = image
128        .repo_tags
129        .iter()
130        .any(|tag| tag != "<none>:<none>" && tag.contains(name_fragment));
131    let digest_match = image
132        .repo_digests
133        .iter()
134        .any(|digest| digest.contains(name_fragment));
135    let label_match = image_labels_match(&image.labels);
136
137    tag_match || digest_match || label_match
138}
139
140fn image_labels_match(labels: &HashMap<String, String>) -> bool {
141    labels
142        .get(LABEL_SOURCE)
143        .is_some_and(|value| value == LABEL_SOURCE_VALUE)
144        || labels
145            .get(LABEL_URL)
146            .is_some_and(|value| value == LABEL_URL_VALUE)
147        || labels
148            .get(LABEL_TITLE)
149            .is_some_and(|value| value == LABEL_TITLE_VALUE)
150}
151
152/// Remove image IDs, returning the number removed.
153async fn remove_image_ids(
154    client: &DockerClient,
155    image_ids: HashSet<String>,
156    force: bool,
157) -> Result<usize, DockerError> {
158    if image_ids.is_empty() {
159        return Ok(0);
160    }
161
162    let remove_options = RemoveImageOptionsBuilder::new().force(force).build();
163    let mut removed = 0usize;
164    for image_id in image_ids {
165        let result = client
166            .inner()
167            .remove_image(&image_id, Some(remove_options.clone()), None)
168            .await;
169        match result {
170            Ok(_) => removed += 1,
171            Err(bollard::errors::Error::DockerResponseServerError {
172                status_code: 404, ..
173            }) => {
174                debug!("Docker image already removed: {}", image_id);
175            }
176            Err(err) => {
177                return Err(DockerError::Image(format!(
178                    "Failed to remove image {image_id}: {err}"
179                )));
180            }
181        }
182    }
183
184    Ok(removed)
185}
186
187/// Build the opencode image from embedded Dockerfile
188///
189/// Shows real-time build progress with streaming output.
190/// Returns the full image:tag string on success.
191///
192/// # Arguments
193/// * `client` - Docker client
194/// * `tag` - Image tag (defaults to IMAGE_TAG_DEFAULT)
195/// * `progress` - Progress reporter for build feedback
196/// * `no_cache` - If true, build without using Docker layer cache
197pub async fn build_image(
198    client: &DockerClient,
199    tag: Option<&str>,
200    progress: &mut ProgressReporter,
201    no_cache: bool,
202    build_args: Option<HashMap<String, String>>,
203) -> Result<String, DockerError> {
204    let tag = tag.unwrap_or(IMAGE_TAG_DEFAULT);
205    let full_name = format!("{IMAGE_NAME_GHCR}:{tag}");
206    debug!("Building image: {} (no_cache: {})", full_name, no_cache);
207
208    // Create tar archive containing Dockerfile
209    let context = create_build_context()
210        .map_err(|e| DockerError::Build(format!("Failed to create build context: {e}")))?;
211
212    // Set up build options
213    // Explicitly use BuildKit builder to support cache mounts (--mount=type=cache)
214    // BuildKit requires a unique session ID for each build
215    let session_id = format!(
216        "opencode-cloud-build-{}",
217        SystemTime::now()
218            .duration_since(UNIX_EPOCH)
219            .unwrap_or_default()
220            .as_nanos()
221    );
222    let build_args = build_args.unwrap_or_default();
223    let options = BuildImageOptions {
224        t: Some(full_name.clone()),
225        dockerfile: "Dockerfile".to_string(),
226        version: BuilderVersion::BuilderBuildKit,
227        session: Some(session_id),
228        rm: true,
229        nocache: no_cache,
230        buildargs: Some(build_args),
231        platform: String::new(),
232        target: String::new(),
233        ..Default::default()
234    };
235
236    // Create build body from context
237    let body: Either<Full<Bytes>, _> = Either::Left(Full::new(Bytes::from(context)));
238
239    // Start build with streaming output
240    let mut stream = client.inner().build_image(options, None, Some(body));
241
242    // Add main build spinner (context prefix like "Building image" is set by caller)
243    progress.add_spinner("build", "Initializing...");
244
245    let mut maybe_image_id = None;
246    let mut log_state = BuildLogState::new();
247
248    while let Some(result) = stream.next().await {
249        let Ok(info) = result else {
250            return Err(handle_stream_error(
251                "Build failed",
252                result.expect_err("checked error").to_string(),
253                &log_state,
254                progress,
255            ));
256        };
257
258        handle_stream_message(&info, progress, &mut log_state);
259
260        if let Some(error_detail) = &info.error_detail
261            && let Some(error_msg) = &error_detail.message
262        {
263            progress.abandon_all(error_msg);
264            let context = format_build_error_with_context(
265                error_msg,
266                &log_state.recent_logs,
267                &log_state.error_logs,
268                &log_state.recent_buildkit_logs,
269            );
270            return Err(DockerError::Build(context));
271        }
272
273        if let Some(aux) = info.aux {
274            match aux {
275                BuildInfoAux::Default(image_id) => {
276                    if let Some(id) = image_id.id {
277                        maybe_image_id = Some(id);
278                    }
279                }
280                BuildInfoAux::BuildKit(status) => {
281                    handle_buildkit_status(&status, progress, &mut log_state);
282                }
283            }
284        }
285    }
286
287    let image_id = maybe_image_id.unwrap_or_else(|| "unknown".to_string());
288    let finish_msg = format!("Build complete: {image_id}");
289    progress.finish("build", &finish_msg);
290
291    Ok(full_name)
292}
293
294struct BuildLogState {
295    recent_logs: VecDeque<String>,
296    error_logs: VecDeque<String>,
297    recent_buildkit_logs: VecDeque<String>,
298    build_log_buffer_size: usize,
299    error_log_buffer_size: usize,
300    last_buildkit_vertex: Option<String>,
301    last_buildkit_vertex_id: Option<String>,
302    export_vertex_id: Option<String>,
303    export_vertex_name: Option<String>,
304    buildkit_logs_by_vertex_id: HashMap<String, String>,
305    vertex_name_by_vertex_id: HashMap<String, String>,
306}
307
308impl BuildLogState {
309    fn new() -> Self {
310        let build_log_buffer_size = read_log_buffer_size(
311            "OPENCODE_DOCKER_BUILD_LOG_TAIL",
312            DEFAULT_BUILD_LOG_BUFFER_SIZE,
313        );
314        let error_log_buffer_size = read_log_buffer_size(
315            "OPENCODE_DOCKER_BUILD_ERROR_TAIL",
316            DEFAULT_ERROR_LOG_BUFFER_SIZE,
317        );
318        Self {
319            recent_logs: VecDeque::with_capacity(build_log_buffer_size),
320            error_logs: VecDeque::with_capacity(error_log_buffer_size),
321            recent_buildkit_logs: VecDeque::with_capacity(build_log_buffer_size),
322            build_log_buffer_size,
323            error_log_buffer_size,
324            last_buildkit_vertex: None,
325            last_buildkit_vertex_id: None,
326            export_vertex_id: None,
327            export_vertex_name: None,
328            buildkit_logs_by_vertex_id: HashMap::new(),
329            vertex_name_by_vertex_id: HashMap::new(),
330        }
331    }
332}
333
334fn handle_stream_message(
335    info: &bollard::models::BuildInfo,
336    progress: &mut ProgressReporter,
337    state: &mut BuildLogState,
338) {
339    let Some(stream_msg) = info.stream.as_deref() else {
340        return;
341    };
342    let msg = stream_msg.trim();
343    if msg.is_empty() {
344        return;
345    }
346
347    if progress.is_plain_output() {
348        eprint!("{stream_msg}");
349    } else {
350        let has_runtime_vertex = state
351            .last_buildkit_vertex
352            .as_deref()
353            .is_some_and(|name| name.starts_with("[runtime "));
354        let is_internal_msg = msg.contains("[internal]");
355        if !(has_runtime_vertex && is_internal_msg) {
356            progress.update_spinner("build", stream_msg);
357        }
358    }
359
360    if state.recent_logs.len() >= state.build_log_buffer_size {
361        state.recent_logs.pop_front();
362    }
363    state.recent_logs.push_back(msg.to_string());
364
365    if is_error_line(msg) {
366        if state.error_logs.len() >= state.error_log_buffer_size {
367            state.error_logs.pop_front();
368        }
369        state.error_logs.push_back(msg.to_string());
370    }
371
372    if msg.starts_with("Step ") {
373        debug!("Build step: {}", msg);
374    }
375}
376
377fn handle_buildkit_status(
378    status: &BuildkitStatusResponse,
379    progress: &mut ProgressReporter,
380    state: &mut BuildLogState,
381) {
382    let latest_logs = append_buildkit_logs(&mut state.buildkit_logs_by_vertex_id, status);
383    update_buildkit_vertex_names(&mut state.vertex_name_by_vertex_id, status);
384    update_export_vertex_from_logs(
385        &latest_logs,
386        &state.vertex_name_by_vertex_id,
387        &mut state.export_vertex_id,
388        &mut state.export_vertex_name,
389    );
390    let (vertex_id, vertex_name) = match select_latest_buildkit_vertex(
391        status,
392        &state.vertex_name_by_vertex_id,
393        state.export_vertex_id.as_deref(),
394        state.export_vertex_name.as_deref(),
395    ) {
396        Some((vertex_id, vertex_name)) => (vertex_id, vertex_name),
397        None => {
398            let Some(log_entry) = latest_logs.last() else {
399                return;
400            };
401            let name = state
402                .vertex_name_by_vertex_id
403                .get(&log_entry.vertex_id)
404                .cloned()
405                .or_else(|| state.last_buildkit_vertex.clone())
406                .unwrap_or_else(|| format_vertex_fallback_label(&log_entry.vertex_id));
407            (log_entry.vertex_id.clone(), name)
408        }
409    };
410    record_buildkit_logs(state, &latest_logs, &vertex_id, &vertex_name);
411    state.last_buildkit_vertex_id = Some(vertex_id.clone());
412    if state.last_buildkit_vertex.as_deref() != Some(&vertex_name) {
413        state.last_buildkit_vertex = Some(vertex_name.clone());
414    }
415
416    let message = if progress.is_plain_output() {
417        vertex_name
418    } else if let Some(log_entry) = latest_logs
419        .iter()
420        .rev()
421        .find(|entry| entry.vertex_id == vertex_id)
422    {
423        format!("{vertex_name} ยท {}", log_entry.message)
424    } else {
425        vertex_name
426    };
427    progress.update_spinner("build", &message);
428
429    if progress.is_plain_output() {
430        for log_entry in latest_logs {
431            eprintln!("[{}] {}", log_entry.vertex_id, log_entry.message);
432        }
433        return;
434    }
435
436    let (Some(current_id), Some(current_name)) = (
437        state.last_buildkit_vertex_id.as_ref(),
438        state.last_buildkit_vertex.as_ref(),
439    ) else {
440        return;
441    };
442
443    let name = state
444        .vertex_name_by_vertex_id
445        .get(current_id)
446        .unwrap_or(current_name);
447    // Keep non-verbose output on the spinner line only.
448    let _ = name;
449}
450
451fn handle_stream_error(
452    prefix: &str,
453    error_str: String,
454    state: &BuildLogState,
455    progress: &mut ProgressReporter,
456) -> DockerError {
457    progress.abandon_all(prefix);
458
459    let buildkit_hint = if error_str.contains("mount")
460        || error_str.contains("--mount")
461        || state
462            .recent_logs
463            .iter()
464            .any(|log| log.contains("--mount") && log.contains("cache"))
465    {
466        "\n\nNote: This Dockerfile uses BuildKit cache mounts (--mount=type=cache).\n\
467         The build is configured to use BuildKit, but the Docker daemon may not support it.\n\
468         Ensure BuildKit is enabled in Docker Desktop settings and the daemon is restarted."
469    } else {
470        ""
471    };
472
473    let context = format!(
474        "{}{}",
475        format_build_error_with_context(
476            &error_str,
477            &state.recent_logs,
478            &state.error_logs,
479            &state.recent_buildkit_logs,
480        ),
481        buildkit_hint
482    );
483    DockerError::Build(context)
484}
485
486fn update_buildkit_vertex_names(
487    vertex_name_by_vertex_id: &mut HashMap<String, String>,
488    status: &BuildkitStatusResponse,
489) {
490    for vertex in &status.vertexes {
491        if vertex.name.is_empty() {
492            continue;
493        }
494        vertex_name_by_vertex_id
495            .entry(vertex.digest.clone())
496            .or_insert_with(|| vertex.name.clone());
497    }
498}
499
500fn select_latest_buildkit_vertex(
501    status: &BuildkitStatusResponse,
502    vertex_name_by_vertex_id: &HashMap<String, String>,
503    export_vertex_id: Option<&str>,
504    export_vertex_name: Option<&str>,
505) -> Option<(String, String)> {
506    if let Some(export_vertex_id) = export_vertex_id {
507        let name = export_vertex_name
508            .map(str::to_string)
509            .or_else(|| vertex_name_by_vertex_id.get(export_vertex_id).cloned())
510            .unwrap_or_else(|| format_vertex_fallback_label(export_vertex_id));
511        return Some((export_vertex_id.to_string(), name));
512    }
513
514    let mut best_runtime: Option<(u32, String, String)> = None;
515    let mut fallback: Option<(String, String)> = None;
516
517    for vertex in &status.vertexes {
518        let name = if vertex.name.is_empty() {
519            vertex_name_by_vertex_id.get(&vertex.digest).cloned()
520        } else {
521            Some(vertex.name.clone())
522        };
523
524        let Some(name) = name else {
525            continue;
526        };
527
528        if fallback.is_none() && !name.starts_with("[internal]") {
529            fallback = Some((vertex.digest.clone(), name.clone()));
530        }
531
532        if let Some(step) = parse_runtime_step(&name) {
533            match &best_runtime {
534                Some((best_step, _, _)) if *best_step >= step => {}
535                _ => {
536                    best_runtime = Some((step, vertex.digest.clone(), name.clone()));
537                }
538            }
539        }
540    }
541
542    if let Some((_, digest, name)) = best_runtime {
543        Some((digest, name))
544    } else {
545        fallback.or_else(|| {
546            status.vertexes.iter().find_map(|vertex| {
547                let name = if vertex.name.is_empty() {
548                    vertex_name_by_vertex_id.get(&vertex.digest).cloned()
549                } else {
550                    Some(vertex.name.clone())
551                };
552                name.map(|resolved| (vertex.digest.clone(), resolved))
553            })
554        })
555    }
556}
557
558fn parse_runtime_step(name: &str) -> Option<u32> {
559    let prefix = "[runtime ";
560    let start = name.find(prefix)? + prefix.len();
561    let rest = &name[start..];
562    let end = rest.find('/')?;
563    rest[..end].trim().parse::<u32>().ok()
564}
565
566fn format_vertex_fallback_label(vertex_id: &str) -> String {
567    let short = vertex_id
568        .strip_prefix("sha256:")
569        .unwrap_or(vertex_id)
570        .chars()
571        .take(12)
572        .collect::<String>();
573    format!("vertex {short}")
574}
575
576fn update_export_vertex_from_logs(
577    latest_logs: &[BuildkitLogEntry],
578    vertex_name_by_vertex_id: &HashMap<String, String>,
579    export_vertex_id: &mut Option<String>,
580    export_vertex_name: &mut Option<String>,
581) {
582    if let Some(entry) = latest_logs
583        .iter()
584        .rev()
585        .find(|log| log.message.trim_start().starts_with("exporting to image"))
586    {
587        *export_vertex_id = Some(entry.vertex_id.clone());
588        if let Some(name) = vertex_name_by_vertex_id.get(&entry.vertex_id) {
589            *export_vertex_name = Some(name.clone());
590        }
591    }
592}
593
594fn record_buildkit_logs(
595    state: &mut BuildLogState,
596    latest_logs: &[BuildkitLogEntry],
597    current_vertex_id: &str,
598    current_vertex_name: &str,
599) {
600    for log_entry in latest_logs {
601        let name = state
602            .vertex_name_by_vertex_id
603            .get(&log_entry.vertex_id)
604            .cloned()
605            .or_else(|| {
606                if log_entry.vertex_id == current_vertex_id {
607                    Some(current_vertex_name.to_string())
608                } else {
609                    None
610                }
611            })
612            .unwrap_or_else(|| format_vertex_fallback_label(&log_entry.vertex_id));
613
614        let message = log_entry.message.replace('\r', "").trim_end().to_string();
615        if message.is_empty() {
616            continue;
617        }
618
619        if state.recent_buildkit_logs.len() >= state.build_log_buffer_size {
620            state.recent_buildkit_logs.pop_front();
621        }
622        state
623            .recent_buildkit_logs
624            .push_back(format!("[{name}] {message}"));
625    }
626}
627
628#[derive(Debug, Clone)]
629struct BuildkitLogEntry {
630    vertex_id: String,
631    message: String,
632}
633
634fn append_buildkit_logs(
635    logs: &mut HashMap<String, String>,
636    status: &BuildkitStatusResponse,
637) -> Vec<BuildkitLogEntry> {
638    let mut latest: Vec<BuildkitLogEntry> = Vec::new();
639
640    for log in &status.logs {
641        let vertex_id = log.vertex.clone();
642        let message = String::from_utf8_lossy(&log.msg).to_string();
643        let entry = logs.entry(vertex_id.clone()).or_default();
644        entry.push_str(&message);
645        latest.push(BuildkitLogEntry { vertex_id, message });
646    }
647
648    latest
649}
650
651/// Pull the opencode image from registry with automatic fallback
652///
653/// Tries GHCR first, falls back to Docker Hub on failure.
654/// Returns the full image:tag string on success.
655pub async fn pull_image(
656    client: &DockerClient,
657    tag: Option<&str>,
658    progress: &mut ProgressReporter,
659) -> Result<String, DockerError> {
660    let tag = tag.unwrap_or(IMAGE_TAG_DEFAULT);
661
662    // Try GHCR first
663    debug!("Attempting to pull from GHCR: {}:{}", IMAGE_NAME_GHCR, tag);
664    let ghcr_err = match pull_from_registry(client, IMAGE_NAME_GHCR, tag, progress).await {
665        Ok(()) => {
666            let full_name = format!("{IMAGE_NAME_GHCR}:{tag}");
667            return Ok(full_name);
668        }
669        Err(e) => e,
670    };
671
672    warn!(
673        "GHCR pull failed: {}. Trying Docker Hub fallback...",
674        ghcr_err
675    );
676
677    // Try Docker Hub as fallback
678    debug!(
679        "Attempting to pull from Docker Hub: {}:{}",
680        IMAGE_NAME_DOCKERHUB, tag
681    );
682    match pull_from_registry(client, IMAGE_NAME_DOCKERHUB, tag, progress).await {
683        Ok(()) => {
684            let full_name = format!("{IMAGE_NAME_DOCKERHUB}:{tag}");
685            Ok(full_name)
686        }
687        Err(dockerhub_err) => Err(DockerError::Pull(format!(
688            "Failed to pull from both registries. GHCR: {ghcr_err}. Docker Hub: {dockerhub_err}"
689        ))),
690    }
691}
692
693/// Maximum number of retry attempts for pull operations
694const MAX_PULL_RETRIES: usize = 3;
695
696/// Pull from a specific registry with retry logic
697async fn pull_from_registry(
698    client: &DockerClient,
699    image: &str,
700    tag: &str,
701    progress: &mut ProgressReporter,
702) -> Result<(), DockerError> {
703    let full_name = format!("{image}:{tag}");
704
705    // Manual retry loop since async closures can't capture mutable references
706    let mut last_error = None;
707    for attempt in 1..=MAX_PULL_RETRIES {
708        debug!(
709            "Pull attempt {}/{} for {}",
710            attempt, MAX_PULL_RETRIES, full_name
711        );
712
713        match do_pull(client, image, tag, progress).await {
714            Ok(()) => return Ok(()),
715            Err(e) => {
716                warn!("Pull attempt {} failed: {}", attempt, e);
717                last_error = Some(e);
718
719                if attempt < MAX_PULL_RETRIES {
720                    // Exponential backoff: 1s, 2s, 4s
721                    let delay_ms = 1000 * (1 << (attempt - 1));
722                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
723                }
724            }
725        }
726    }
727
728    Err(last_error.unwrap_or_else(|| {
729        DockerError::Pull(format!(
730            "Pull failed for {full_name} after {MAX_PULL_RETRIES} attempts"
731        ))
732    }))
733}
734
735/// Perform the actual pull operation
736async fn do_pull(
737    client: &DockerClient,
738    image: &str,
739    tag: &str,
740    progress: &mut ProgressReporter,
741) -> Result<(), DockerError> {
742    let full_name = format!("{image}:{tag}");
743
744    let options = CreateImageOptions {
745        from_image: Some(image.to_string()),
746        tag: Some(tag.to_string()),
747        platform: String::new(),
748        ..Default::default()
749    };
750
751    let mut stream = client.inner().create_image(Some(options), None, None);
752
753    // Add main spinner for overall progress
754    progress.add_spinner("pull", &format!("Pulling {full_name}..."));
755
756    while let Some(result) = stream.next().await {
757        match result {
758            Ok(info) => {
759                // Handle errors from the stream
760                if let Some(error_detail) = &info.error_detail
761                    && let Some(error_msg) = &error_detail.message
762                {
763                    progress.abandon_all(error_msg);
764                    return Err(DockerError::Pull(error_msg.to_string()));
765                }
766
767                // Handle layer progress
768                if let Some(layer_id) = &info.id {
769                    let status = info.status.as_deref().unwrap_or("");
770
771                    match status {
772                        "Already exists" => {
773                            progress.finish(layer_id, "Already exists");
774                        }
775                        "Pull complete" => {
776                            progress.finish(layer_id, "Pull complete");
777                        }
778                        "Downloading" | "Extracting" => {
779                            if let Some(progress_detail) = &info.progress_detail {
780                                let current = progress_detail.current.unwrap_or(0) as u64;
781                                let total = progress_detail.total.unwrap_or(0) as u64;
782
783                                if total > 0 {
784                                    progress.update_layer(layer_id, current, total, status);
785                                }
786                            }
787                        }
788                        _ => {
789                            // Other statuses (Waiting, Verifying, etc.)
790                            progress.update_spinner(layer_id, status);
791                        }
792                    }
793                } else if let Some(status) = &info.status {
794                    // Overall status messages (no layer id)
795                    progress.update_spinner("pull", status);
796                }
797            }
798            Err(e) => {
799                progress.abandon_all("Pull failed");
800                return Err(DockerError::Pull(format!("Pull failed: {e}")));
801            }
802        }
803    }
804
805    progress.finish("pull", &format!("Pull complete: {full_name}"));
806    Ok(())
807}
808
809/// Format a build error with recent log context for actionable debugging
810fn format_build_error_with_context(
811    error: &str,
812    recent_logs: &VecDeque<String>,
813    error_logs: &VecDeque<String>,
814    recent_buildkit_logs: &VecDeque<String>,
815) -> String {
816    let mut message = String::new();
817
818    // Add main error message
819    message.push_str(error);
820
821    // Add captured error lines if they differ from recent logs
822    // (these are error-like lines that may have scrolled off)
823    if !error_logs.is_empty() {
824        // Check if error_logs contains lines not in recent_logs
825        let recent_set: std::collections::HashSet<_> = recent_logs.iter().collect();
826        let unique_errors: Vec<_> = error_logs
827            .iter()
828            .filter(|line| !recent_set.contains(line))
829            .collect();
830
831        if !unique_errors.is_empty() {
832            message.push_str("\n\nPotential errors detected during build:");
833            for line in unique_errors {
834                message.push_str("\n  ");
835                message.push_str(line);
836            }
837        }
838    }
839
840    // Add recent BuildKit log context if available
841    if !recent_buildkit_logs.is_empty() {
842        message.push_str("\n\nRecent BuildKit output:");
843        for line in recent_buildkit_logs {
844            message.push_str("\n  ");
845            message.push_str(line);
846        }
847    }
848
849    // Add recent log context if available
850    if !recent_logs.is_empty() {
851        message.push_str("\n\nRecent build output:");
852        for line in recent_logs {
853            message.push_str("\n  ");
854            message.push_str(line);
855        }
856    } else if recent_buildkit_logs.is_empty() {
857        message.push_str("\n\nNo build output was received from the Docker daemon.");
858        message.push_str("\nThis usually means the build failed before any logs were streamed.");
859    }
860
861    // Add actionable suggestions based on common error patterns
862    let error_lower = error.to_lowercase();
863    if error_lower.contains("network")
864        || error_lower.contains("connection")
865        || error_lower.contains("timeout")
866    {
867        message.push_str("\n\nSuggestion: Check your network connection and Docker's ability to reach the internet.");
868    } else if error_lower.contains("disk")
869        || error_lower.contains("space")
870        || error_lower.contains("no space")
871    {
872        message.push_str("\n\nSuggestion: Free up disk space with 'docker system prune' or check available storage.");
873    } else if error_lower.contains("permission") || error_lower.contains("denied") {
874        message.push_str("\n\nSuggestion: Check Docker permissions. You may need to add your user to the 'docker' group.");
875    }
876
877    message
878}
879
880/// Create a gzipped tar archive containing the Dockerfile
881fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
882    let mut archive_buffer = Vec::new();
883
884    {
885        let encoder = GzEncoder::new(&mut archive_buffer, Compression::default());
886        let mut tar = TarBuilder::new(encoder);
887
888        // Add Dockerfile to archive
889        let dockerfile_bytes = DOCKERFILE.as_bytes();
890        let mut header = tar::Header::new_gnu();
891        header.set_path("Dockerfile")?;
892        header.set_size(dockerfile_bytes.len() as u64);
893        header.set_mode(0o644);
894        header.set_cksum();
895
896        tar.append(&header, dockerfile_bytes)?;
897        tar.finish()?;
898
899        // Finish gzip encoding
900        let encoder = tar.into_inner()?;
901        encoder.finish()?;
902    }
903
904    Ok(archive_buffer)
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use bollard::models::ImageSummary;
911    use std::collections::HashMap;
912
913    fn make_image_summary(
914        id: &str,
915        tags: Vec<&str>,
916        digests: Vec<&str>,
917        labels: HashMap<String, String>,
918    ) -> ImageSummary {
919        ImageSummary {
920            id: id.to_string(),
921            parent_id: String::new(),
922            repo_tags: tags.into_iter().map(|tag| tag.to_string()).collect(),
923            repo_digests: digests
924                .into_iter()
925                .map(|digest| digest.to_string())
926                .collect(),
927            created: 0,
928            size: 0,
929            shared_size: -1,
930            labels,
931            containers: 0,
932            manifests: None,
933            descriptor: None,
934        }
935    }
936
937    #[test]
938    fn create_build_context_succeeds() {
939        let context = create_build_context().expect("should create context");
940        assert!(!context.is_empty(), "context should not be empty");
941
942        // Verify it's gzip-compressed (gzip magic bytes)
943        assert_eq!(context[0], 0x1f, "should be gzip compressed");
944        assert_eq!(context[1], 0x8b, "should be gzip compressed");
945    }
946
947    #[test]
948    fn default_tag_is_latest() {
949        assert_eq!(IMAGE_TAG_DEFAULT, "latest");
950    }
951
952    #[test]
953    fn format_build_error_includes_recent_logs() {
954        let mut logs = VecDeque::new();
955        logs.push_back("Step 1/5 : FROM ubuntu:24.04".to_string());
956        logs.push_back("Step 2/5 : RUN apt-get update".to_string());
957        logs.push_back("E: Unable to fetch some archives".to_string());
958        let error_logs = VecDeque::new();
959        let buildkit_logs = VecDeque::new();
960
961        let result = format_build_error_with_context(
962            "Build failed: exit code 1",
963            &logs,
964            &error_logs,
965            &buildkit_logs,
966        );
967
968        assert!(result.contains("Build failed: exit code 1"));
969        assert!(result.contains("Recent build output:"));
970        assert!(result.contains("Step 1/5"));
971        assert!(result.contains("Unable to fetch"));
972    }
973
974    #[test]
975    fn format_build_error_handles_empty_logs() {
976        let logs = VecDeque::new();
977        let error_logs = VecDeque::new();
978        let buildkit_logs = VecDeque::new();
979        let result =
980            format_build_error_with_context("Stream error", &logs, &error_logs, &buildkit_logs);
981
982        assert!(result.contains("Stream error"));
983        assert!(!result.contains("Recent build output:"));
984    }
985
986    #[test]
987    fn format_build_error_adds_network_suggestion() {
988        let logs = VecDeque::new();
989        let error_logs = VecDeque::new();
990        let buildkit_logs = VecDeque::new();
991        let result = format_build_error_with_context(
992            "connection timeout",
993            &logs,
994            &error_logs,
995            &buildkit_logs,
996        );
997
998        assert!(result.contains("Check your network connection"));
999    }
1000
1001    #[test]
1002    fn format_build_error_adds_disk_suggestion() {
1003        let logs = VecDeque::new();
1004        let error_logs = VecDeque::new();
1005        let buildkit_logs = VecDeque::new();
1006        let result = format_build_error_with_context(
1007            "no space left on device",
1008            &logs,
1009            &error_logs,
1010            &buildkit_logs,
1011        );
1012
1013        assert!(result.contains("Free up disk space"));
1014    }
1015
1016    #[test]
1017    fn format_build_error_shows_error_lines_separately() {
1018        let mut recent_logs = VecDeque::new();
1019        recent_logs.push_back("Compiling foo v1.0".to_string());
1020        recent_logs.push_back("Successfully installed bar".to_string());
1021
1022        let mut error_logs = VecDeque::new();
1023        error_logs.push_back("error: failed to compile dust".to_string());
1024        error_logs.push_back("error: failed to compile glow".to_string());
1025
1026        let buildkit_logs = VecDeque::new();
1027        let result = format_build_error_with_context(
1028            "Build failed",
1029            &recent_logs,
1030            &error_logs,
1031            &buildkit_logs,
1032        );
1033
1034        assert!(result.contains("Potential errors detected during build:"));
1035        assert!(result.contains("failed to compile dust"));
1036        assert!(result.contains("failed to compile glow"));
1037    }
1038
1039    #[test]
1040    fn is_error_line_detects_errors() {
1041        assert!(is_error_line("error: something failed"));
1042        assert!(is_error_line("Error: build failed"));
1043        assert!(is_error_line("Failed to install package"));
1044        assert!(is_error_line("cannot find module"));
1045        assert!(is_error_line("Unable to locate package"));
1046        assert!(!is_error_line("Compiling foo v1.0"));
1047        assert!(!is_error_line("Successfully installed"));
1048    }
1049
1050    #[test]
1051    fn collect_image_ids_matches_labels() {
1052        let mut labels = HashMap::new();
1053        labels.insert(LABEL_SOURCE.to_string(), LABEL_SOURCE_VALUE.to_string());
1054
1055        let images = vec![
1056            make_image_summary("sha256:opencode", vec![], vec![], labels),
1057            make_image_summary(
1058                "sha256:other",
1059                vec!["busybox:latest"],
1060                vec![],
1061                HashMap::new(),
1062            ),
1063        ];
1064
1065        let ids = collect_image_ids(&images, "opencode-cloud-sandbox");
1066        assert!(ids.contains("sha256:opencode"));
1067        assert!(!ids.contains("sha256:other"));
1068    }
1069}