1use 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
27const DEFAULT_BUILD_LOG_BUFFER_SIZE: usize = 20;
29
30const DEFAULT_ERROR_LOG_BUFFER_SIZE: usize = 10;
32
33fn 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
44fn 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
55pub 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
73pub 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
89async 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
109fn 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
152async 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
187pub 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 let context = create_build_context()
210 .map_err(|e| DockerError::Build(format!("Failed to create build context: {e}")))?;
211
212 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 let body: Either<Full<Bytes>, _> = Either::Left(Full::new(Bytes::from(context)));
238
239 let mut stream = client.inner().build_image(options, None, Some(body));
241
242 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 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
651pub 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 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 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
693const MAX_PULL_RETRIES: usize = 3;
695
696async 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 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 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
735async 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 progress.add_spinner("pull", &format!("Pulling {full_name}..."));
755
756 while let Some(result) = stream.next().await {
757 match result {
758 Ok(info) => {
759 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 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 progress.update_spinner(layer_id, status);
791 }
792 }
793 } else if let Some(status) = &info.status {
794 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
809fn 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 message.push_str(error);
820
821 if !error_logs.is_empty() {
824 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 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 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 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
880fn 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 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 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 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}