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: {}. Docker Hub: {}",
207 ghcr_err, dockerhub_err
208 ))),
209 }
210}
211
212const MAX_PULL_RETRIES: usize = 3;
214
215async 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 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 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
255async 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 progress.add_spinner("pull", &format!("Pulling {full_name}..."));
274
275 while let Some(result) = stream.next().await {
276 match result {
277 Ok(info) => {
278 if let Some(error_msg) = info.error {
280 progress.abandon_all(&error_msg);
281 return Err(DockerError::Pull(error_msg));
282 }
283
284 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 progress.update_spinner(layer_id, status);
308 }
309 }
310 } else if let Some(status) = &info.status {
311 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
326fn 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 message.push_str(error);
336
337 if !error_logs.is_empty() {
340 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 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 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
384fn 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 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 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 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}