opencode_cloud_core/docker/
image.rs1use 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
20const BUILD_LOG_BUFFER_SIZE: usize = 20;
22
23const ERROR_LOG_BUFFER_SIZE: usize = 10;
25
26fn 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
37pub 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
55pub 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 let context = create_build_context()
77 .map_err(|e| DockerError::Build(format!("Failed to create build context: {e}")))?;
78
79 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 let body = Bytes::from(context);
90
91 let mut stream = client.inner().build_image(options, None, Some(body));
93
94 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 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 if recent_logs.len() >= BUILD_LOG_BUFFER_SIZE {
112 recent_logs.pop_front();
113 }
114 recent_logs.push_back(msg.to_string());
115
116 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 if msg.starts_with("Step ") {
126 debug!("Build step: {}", msg);
127 }
128 }
129 }
130
131 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 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 }
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
169pub 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 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 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
211const MAX_PULL_RETRIES: usize = 3;
213
214async 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 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 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
253async 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 progress.add_spinner("pull", &format!("Pulling {full_name}..."));
272
273 while let Some(result) = stream.next().await {
274 match result {
275 Ok(info) => {
276 if let Some(error_msg) = info.error {
278 progress.abandon_all(&error_msg);
279 return Err(DockerError::Pull(error_msg));
280 }
281
282 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 progress.update_spinner(layer_id, status);
306 }
307 }
308 } else if let Some(status) = &info.status {
309 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
324fn 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 message.push_str(error);
334
335 if !error_logs.is_empty() {
338 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 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 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
382fn 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 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 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 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}