@opencode-cloud/core 3.0.15 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,21 +7,35 @@ use super::progress::ProgressReporter;
7
7
  use super::{
8
8
  DOCKERFILE, DockerClient, DockerError, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT,
9
9
  };
10
- use bollard::image::{BuildImageOptions, CreateImageOptions};
10
+ use bollard::image::{BuildImageOptions, BuilderVersion, CreateImageOptions};
11
+ use bollard::moby::buildkit::v1::StatusResponse as BuildkitStatusResponse;
11
12
  use bollard::models::BuildInfoAux;
12
13
  use bytes::Bytes;
13
14
  use flate2::Compression;
14
15
  use flate2::write::GzEncoder;
15
16
  use futures_util::StreamExt;
16
- use std::collections::VecDeque;
17
+ use std::collections::{HashMap, VecDeque};
18
+ use std::env;
19
+ use std::time::{SystemTime, UNIX_EPOCH};
17
20
  use tar::Builder as TarBuilder;
18
21
  use tracing::{debug, warn};
19
22
 
20
- /// Maximum number of recent build log lines to capture for error context
21
- const BUILD_LOG_BUFFER_SIZE: usize = 20;
23
+ /// Default number of recent build log lines to capture for error context
24
+ const DEFAULT_BUILD_LOG_BUFFER_SIZE: usize = 20;
22
25
 
23
- /// Maximum number of error lines to capture separately
24
- const ERROR_LOG_BUFFER_SIZE: usize = 10;
26
+ /// Default number of error lines to capture separately
27
+ const DEFAULT_ERROR_LOG_BUFFER_SIZE: usize = 10;
28
+
29
+ /// Read a log buffer size from env with bounds
30
+ fn read_log_buffer_size(var_name: &str, default: usize) -> usize {
31
+ let Ok(value) = env::var(var_name) else {
32
+ return default;
33
+ };
34
+ let Ok(parsed) = value.trim().parse::<usize>() else {
35
+ return default;
36
+ };
37
+ parsed.clamp(5, 500)
38
+ }
25
39
 
26
40
  /// Check if a line looks like an error message
27
41
  fn is_error_line(line: &str) -> bool {
@@ -77,9 +91,20 @@ pub async fn build_image(
77
91
  .map_err(|e| DockerError::Build(format!("Failed to create build context: {e}")))?;
78
92
 
79
93
  // Set up build options
94
+ // Explicitly use BuildKit builder to support cache mounts (--mount=type=cache)
95
+ // BuildKit requires a unique session ID for each build
96
+ let session_id = format!(
97
+ "opencode-cloud-build-{}",
98
+ SystemTime::now()
99
+ .duration_since(UNIX_EPOCH)
100
+ .unwrap_or_default()
101
+ .as_nanos()
102
+ );
80
103
  let options = BuildImageOptions {
81
104
  t: full_name.clone(),
82
105
  dockerfile: "Dockerfile".to_string(),
106
+ version: BuilderVersion::BuilderBuildKit,
107
+ session: Some(session_id),
83
108
  rm: true,
84
109
  nocache: no_cache,
85
110
  ..Default::default()
@@ -95,67 +120,42 @@ pub async fn build_image(
95
120
  progress.add_spinner("build", "Initializing...");
96
121
 
97
122
  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);
123
+ let mut log_state = BuildLogState::new();
100
124
 
101
125
  while let Some(result) = stream.next().await {
102
- match result {
103
- Ok(info) => {
104
- // Handle stream output (build log messages)
105
- if let Some(stream_msg) = info.stream {
106
- let msg = stream_msg.trim();
107
- if !msg.is_empty() {
108
- progress.update_spinner("build", msg);
109
-
110
- // Capture recent log lines for error context
111
- if recent_logs.len() >= BUILD_LOG_BUFFER_SIZE {
112
- recent_logs.pop_front();
113
- }
114
- recent_logs.push_back(msg.to_string());
115
-
116
- // Also capture error-like lines separately (they might scroll off)
117
- if is_error_line(msg) {
118
- if error_logs.len() >= ERROR_LOG_BUFFER_SIZE {
119
- error_logs.pop_front();
120
- }
121
- error_logs.push_back(msg.to_string());
122
- }
126
+ let Ok(info) = result else {
127
+ return Err(handle_stream_error(
128
+ "Build failed",
129
+ result.expect_err("checked error").to_string(),
130
+ &log_state,
131
+ progress,
132
+ ));
133
+ };
134
+
135
+ handle_stream_message(&info, progress, &mut log_state);
136
+
137
+ if let Some(error_msg) = info.error {
138
+ progress.abandon_all(&error_msg);
139
+ let context = format_build_error_with_context(
140
+ &error_msg,
141
+ &log_state.recent_logs,
142
+ &log_state.error_logs,
143
+ &log_state.recent_buildkit_logs,
144
+ );
145
+ return Err(DockerError::Build(context));
146
+ }
123
147
 
124
- // Capture step information for better progress
125
- if msg.starts_with("Step ") {
126
- debug!("Build step: {}", msg);
127
- }
148
+ if let Some(aux) = info.aux {
149
+ match aux {
150
+ BuildInfoAux::Default(image_id) => {
151
+ if let Some(id) = image_id.id {
152
+ maybe_image_id = Some(id);
128
153
  }
129
154
  }
130
-
131
- // Handle error messages
132
- if let Some(error_msg) = info.error {
133
- progress.abandon_all(&error_msg);
134
- let context =
135
- format_build_error_with_context(&error_msg, &recent_logs, &error_logs);
136
- return Err(DockerError::Build(context));
137
- }
138
-
139
- // Capture the image ID from aux field
140
- if let Some(aux) = info.aux {
141
- match aux {
142
- BuildInfoAux::Default(image_id) => {
143
- if let Some(id) = image_id.id {
144
- maybe_image_id = Some(id);
145
- }
146
- }
147
- BuildInfoAux::BuildKit(_) => {
148
- // BuildKit responses are handled via stream messages
149
- }
150
- }
155
+ BuildInfoAux::BuildKit(status) => {
156
+ handle_buildkit_status(&status, progress, &mut log_state);
151
157
  }
152
158
  }
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
159
  }
160
160
  }
161
161
 
@@ -166,6 +166,317 @@ pub async fn build_image(
166
166
  Ok(full_name)
167
167
  }
168
168
 
169
+ struct BuildLogState {
170
+ recent_logs: VecDeque<String>,
171
+ error_logs: VecDeque<String>,
172
+ recent_buildkit_logs: VecDeque<String>,
173
+ build_log_buffer_size: usize,
174
+ error_log_buffer_size: usize,
175
+ last_buildkit_vertex: Option<String>,
176
+ last_buildkit_vertex_id: Option<String>,
177
+ buildkit_logs_by_vertex_id: HashMap<String, String>,
178
+ vertex_name_by_vertex_id: HashMap<String, String>,
179
+ }
180
+
181
+ impl BuildLogState {
182
+ fn new() -> Self {
183
+ let build_log_buffer_size = read_log_buffer_size(
184
+ "OPENCODE_DOCKER_BUILD_LOG_TAIL",
185
+ DEFAULT_BUILD_LOG_BUFFER_SIZE,
186
+ );
187
+ let error_log_buffer_size = read_log_buffer_size(
188
+ "OPENCODE_DOCKER_BUILD_ERROR_TAIL",
189
+ DEFAULT_ERROR_LOG_BUFFER_SIZE,
190
+ );
191
+ Self {
192
+ recent_logs: VecDeque::with_capacity(build_log_buffer_size),
193
+ error_logs: VecDeque::with_capacity(error_log_buffer_size),
194
+ recent_buildkit_logs: VecDeque::with_capacity(build_log_buffer_size),
195
+ build_log_buffer_size,
196
+ error_log_buffer_size,
197
+ last_buildkit_vertex: None,
198
+ last_buildkit_vertex_id: None,
199
+ buildkit_logs_by_vertex_id: HashMap::new(),
200
+ vertex_name_by_vertex_id: HashMap::new(),
201
+ }
202
+ }
203
+ }
204
+
205
+ fn handle_stream_message(
206
+ info: &bollard::models::BuildInfo,
207
+ progress: &mut ProgressReporter,
208
+ state: &mut BuildLogState,
209
+ ) {
210
+ let Some(stream_msg) = info.stream.as_deref() else {
211
+ return;
212
+ };
213
+ let msg = stream_msg.trim();
214
+ if msg.is_empty() {
215
+ return;
216
+ }
217
+
218
+ if progress.is_plain_output() {
219
+ eprint!("{stream_msg}");
220
+ } else {
221
+ let has_runtime_vertex = state
222
+ .last_buildkit_vertex
223
+ .as_deref()
224
+ .is_some_and(|name| name.starts_with("[runtime "));
225
+ let is_internal_msg = msg.contains("[internal]");
226
+ if !(has_runtime_vertex && is_internal_msg) {
227
+ progress.update_spinner("build", stream_msg);
228
+ }
229
+ }
230
+
231
+ if state.recent_logs.len() >= state.build_log_buffer_size {
232
+ state.recent_logs.pop_front();
233
+ }
234
+ state.recent_logs.push_back(msg.to_string());
235
+
236
+ if is_error_line(msg) {
237
+ if state.error_logs.len() >= state.error_log_buffer_size {
238
+ state.error_logs.pop_front();
239
+ }
240
+ state.error_logs.push_back(msg.to_string());
241
+ }
242
+
243
+ if msg.starts_with("Step ") {
244
+ debug!("Build step: {}", msg);
245
+ }
246
+ }
247
+
248
+ fn handle_buildkit_status(
249
+ status: &BuildkitStatusResponse,
250
+ progress: &mut ProgressReporter,
251
+ state: &mut BuildLogState,
252
+ ) {
253
+ let latest_logs = append_buildkit_logs(&mut state.buildkit_logs_by_vertex_id, status);
254
+ update_buildkit_vertex_names(&mut state.vertex_name_by_vertex_id, status);
255
+ let (vertex_id, vertex_name) =
256
+ match select_latest_buildkit_vertex(status, &state.vertex_name_by_vertex_id) {
257
+ Some((vertex_id, vertex_name)) => (vertex_id, vertex_name),
258
+ None => {
259
+ let Some(log_entry) = latest_logs.last() else {
260
+ return;
261
+ };
262
+ let name = state
263
+ .vertex_name_by_vertex_id
264
+ .get(&log_entry.vertex_id)
265
+ .cloned()
266
+ .or_else(|| state.last_buildkit_vertex.clone())
267
+ .unwrap_or_else(|| format_vertex_fallback_label(&log_entry.vertex_id));
268
+ (log_entry.vertex_id.clone(), name)
269
+ }
270
+ };
271
+ record_buildkit_logs(state, &latest_logs, &vertex_id, &vertex_name);
272
+ state.last_buildkit_vertex_id = Some(vertex_id);
273
+ if state.last_buildkit_vertex.as_deref() != Some(&vertex_name) {
274
+ state.last_buildkit_vertex = Some(vertex_name.clone());
275
+ }
276
+
277
+ let message = if progress.is_plain_output() {
278
+ vertex_name
279
+ } else if let Some(log_entry) = latest_logs.last() {
280
+ format!("{vertex_name} · {}", log_entry.message)
281
+ } else {
282
+ vertex_name
283
+ };
284
+ progress.update_spinner("build", &message);
285
+
286
+ if progress.is_plain_output() {
287
+ for log_entry in latest_logs {
288
+ eprintln!("[{}] {}", log_entry.vertex_id, log_entry.message);
289
+ }
290
+ return;
291
+ }
292
+
293
+ let (Some(current_id), Some(current_name)) = (
294
+ state.last_buildkit_vertex_id.as_ref(),
295
+ state.last_buildkit_vertex.as_ref(),
296
+ ) else {
297
+ return;
298
+ };
299
+
300
+ let name = state
301
+ .vertex_name_by_vertex_id
302
+ .get(current_id)
303
+ .unwrap_or(current_name);
304
+ // Keep non-verbose output on the spinner line only.
305
+ let _ = name;
306
+ }
307
+
308
+ fn handle_stream_error(
309
+ prefix: &str,
310
+ error_str: String,
311
+ state: &BuildLogState,
312
+ progress: &mut ProgressReporter,
313
+ ) -> DockerError {
314
+ progress.abandon_all(prefix);
315
+
316
+ let buildkit_hint = if error_str.contains("mount")
317
+ || error_str.contains("--mount")
318
+ || state
319
+ .recent_logs
320
+ .iter()
321
+ .any(|log| log.contains("--mount") && log.contains("cache"))
322
+ {
323
+ "\n\nNote: This Dockerfile uses BuildKit cache mounts (--mount=type=cache).\n\
324
+ The build is configured to use BuildKit, but the Docker daemon may not support it.\n\
325
+ Ensure BuildKit is enabled in Docker Desktop settings and the daemon is restarted."
326
+ } else {
327
+ ""
328
+ };
329
+
330
+ let context = format!(
331
+ "{}{}",
332
+ format_build_error_with_context(
333
+ &error_str,
334
+ &state.recent_logs,
335
+ &state.error_logs,
336
+ &state.recent_buildkit_logs,
337
+ ),
338
+ buildkit_hint
339
+ );
340
+ DockerError::Build(context)
341
+ }
342
+
343
+ fn update_buildkit_vertex_names(
344
+ vertex_name_by_vertex_id: &mut HashMap<String, String>,
345
+ status: &BuildkitStatusResponse,
346
+ ) {
347
+ for vertex in &status.vertexes {
348
+ if vertex.name.is_empty() {
349
+ continue;
350
+ }
351
+ vertex_name_by_vertex_id
352
+ .entry(vertex.digest.clone())
353
+ .or_insert_with(|| vertex.name.clone());
354
+ }
355
+ }
356
+
357
+ fn select_latest_buildkit_vertex(
358
+ status: &BuildkitStatusResponse,
359
+ vertex_name_by_vertex_id: &HashMap<String, String>,
360
+ ) -> Option<(String, String)> {
361
+ let mut best_runtime: Option<(u32, String, String)> = None;
362
+ let mut fallback: Option<(String, String)> = None;
363
+
364
+ for vertex in &status.vertexes {
365
+ let name = if vertex.name.is_empty() {
366
+ vertex_name_by_vertex_id.get(&vertex.digest).cloned()
367
+ } else {
368
+ Some(vertex.name.clone())
369
+ };
370
+
371
+ let Some(name) = name else {
372
+ continue;
373
+ };
374
+
375
+ if fallback.is_none() && !name.starts_with("[internal]") {
376
+ fallback = Some((vertex.digest.clone(), name.clone()));
377
+ }
378
+
379
+ if let Some(step) = parse_runtime_step(&name) {
380
+ match &best_runtime {
381
+ Some((best_step, _, _)) if *best_step >= step => {}
382
+ _ => {
383
+ best_runtime = Some((step, vertex.digest.clone(), name.clone()));
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ if let Some((_, digest, name)) = best_runtime {
390
+ Some((digest, name))
391
+ } else {
392
+ fallback.or_else(|| {
393
+ status.vertexes.iter().find_map(|vertex| {
394
+ let name = if vertex.name.is_empty() {
395
+ vertex_name_by_vertex_id.get(&vertex.digest).cloned()
396
+ } else {
397
+ Some(vertex.name.clone())
398
+ };
399
+ name.map(|resolved| (vertex.digest.clone(), resolved))
400
+ })
401
+ })
402
+ }
403
+ }
404
+
405
+ fn parse_runtime_step(name: &str) -> Option<u32> {
406
+ let prefix = "[runtime ";
407
+ let start = name.find(prefix)? + prefix.len();
408
+ let rest = &name[start..];
409
+ let end = rest.find('/')?;
410
+ rest[..end].trim().parse::<u32>().ok()
411
+ }
412
+
413
+ fn format_vertex_fallback_label(vertex_id: &str) -> String {
414
+ let short = vertex_id
415
+ .strip_prefix("sha256:")
416
+ .unwrap_or(vertex_id)
417
+ .chars()
418
+ .take(12)
419
+ .collect::<String>();
420
+ format!("vertex {short}")
421
+ }
422
+
423
+ fn record_buildkit_logs(
424
+ state: &mut BuildLogState,
425
+ latest_logs: &[BuildkitLogEntry],
426
+ current_vertex_id: &str,
427
+ current_vertex_name: &str,
428
+ ) {
429
+ for log_entry in latest_logs {
430
+ let name = state
431
+ .vertex_name_by_vertex_id
432
+ .get(&log_entry.vertex_id)
433
+ .cloned()
434
+ .or_else(|| {
435
+ if log_entry.vertex_id == current_vertex_id {
436
+ Some(current_vertex_name.to_string())
437
+ } else {
438
+ None
439
+ }
440
+ })
441
+ .unwrap_or_else(|| format_vertex_fallback_label(&log_entry.vertex_id));
442
+
443
+ let message = log_entry.message.replace('\r', "").trim_end().to_string();
444
+ if message.is_empty() {
445
+ continue;
446
+ }
447
+
448
+ if state.recent_buildkit_logs.len() >= state.build_log_buffer_size {
449
+ state.recent_buildkit_logs.pop_front();
450
+ }
451
+ state
452
+ .recent_buildkit_logs
453
+ .push_back(format!("[{name}] {message}"));
454
+ }
455
+ }
456
+
457
+ #[derive(Debug, Clone)]
458
+ struct BuildkitLogEntry {
459
+ vertex_id: String,
460
+ message: String,
461
+ }
462
+
463
+ fn append_buildkit_logs(
464
+ logs: &mut HashMap<String, String>,
465
+ status: &BuildkitStatusResponse,
466
+ ) -> Vec<BuildkitLogEntry> {
467
+ let mut latest: Vec<BuildkitLogEntry> = Vec::new();
468
+
469
+ for log in &status.logs {
470
+ let vertex_id = log.vertex.clone();
471
+ let message = String::from_utf8_lossy(&log.msg).to_string();
472
+ let entry = logs.entry(vertex_id.clone()).or_default();
473
+ entry.push_str(&message);
474
+ latest.push(BuildkitLogEntry { vertex_id, message });
475
+ }
476
+
477
+ latest
478
+ }
479
+
169
480
  /// Pull the opencode image from registry with automatic fallback
170
481
  ///
171
482
  /// Tries GHCR first, falls back to Docker Hub on failure.
@@ -326,6 +637,7 @@ fn format_build_error_with_context(
326
637
  error: &str,
327
638
  recent_logs: &VecDeque<String>,
328
639
  error_logs: &VecDeque<String>,
640
+ recent_buildkit_logs: &VecDeque<String>,
329
641
  ) -> String {
330
642
  let mut message = String::new();
331
643
 
@@ -351,6 +663,15 @@ fn format_build_error_with_context(
351
663
  }
352
664
  }
353
665
 
666
+ // Add recent BuildKit log context if available
667
+ if !recent_buildkit_logs.is_empty() {
668
+ message.push_str("\n\nRecent BuildKit output:");
669
+ for line in recent_buildkit_logs {
670
+ message.push_str("\n ");
671
+ message.push_str(line);
672
+ }
673
+ }
674
+
354
675
  // Add recent log context if available
355
676
  if !recent_logs.is_empty() {
356
677
  message.push_str("\n\nRecent build output:");
@@ -358,6 +679,9 @@ fn format_build_error_with_context(
358
679
  message.push_str("\n ");
359
680
  message.push_str(line);
360
681
  }
682
+ } else if recent_buildkit_logs.is_empty() {
683
+ message.push_str("\n\nNo build output was received from the Docker daemon.");
684
+ message.push_str("\nThis usually means the build failed before any logs were streamed.");
361
685
  }
362
686
 
363
687
  // Add actionable suggestions based on common error patterns
@@ -432,9 +756,14 @@ mod tests {
432
756
  logs.push_back("Step 2/5 : RUN apt-get update".to_string());
433
757
  logs.push_back("E: Unable to fetch some archives".to_string());
434
758
  let error_logs = VecDeque::new();
759
+ let buildkit_logs = VecDeque::new();
435
760
 
436
- let result =
437
- format_build_error_with_context("Build failed: exit code 1", &logs, &error_logs);
761
+ let result = format_build_error_with_context(
762
+ "Build failed: exit code 1",
763
+ &logs,
764
+ &error_logs,
765
+ &buildkit_logs,
766
+ );
438
767
 
439
768
  assert!(result.contains("Build failed: exit code 1"));
440
769
  assert!(result.contains("Recent build output:"));
@@ -446,7 +775,9 @@ mod tests {
446
775
  fn format_build_error_handles_empty_logs() {
447
776
  let logs = VecDeque::new();
448
777
  let error_logs = VecDeque::new();
449
- let result = format_build_error_with_context("Stream error", &logs, &error_logs);
778
+ let buildkit_logs = VecDeque::new();
779
+ let result =
780
+ format_build_error_with_context("Stream error", &logs, &error_logs, &buildkit_logs);
450
781
 
451
782
  assert!(result.contains("Stream error"));
452
783
  assert!(!result.contains("Recent build output:"));
@@ -456,7 +787,13 @@ mod tests {
456
787
  fn format_build_error_adds_network_suggestion() {
457
788
  let logs = VecDeque::new();
458
789
  let error_logs = VecDeque::new();
459
- let result = format_build_error_with_context("connection timeout", &logs, &error_logs);
790
+ let buildkit_logs = VecDeque::new();
791
+ let result = format_build_error_with_context(
792
+ "connection timeout",
793
+ &logs,
794
+ &error_logs,
795
+ &buildkit_logs,
796
+ );
460
797
 
461
798
  assert!(result.contains("Check your network connection"));
462
799
  }
@@ -465,7 +802,13 @@ mod tests {
465
802
  fn format_build_error_adds_disk_suggestion() {
466
803
  let logs = VecDeque::new();
467
804
  let error_logs = VecDeque::new();
468
- let result = format_build_error_with_context("no space left on device", &logs, &error_logs);
805
+ let buildkit_logs = VecDeque::new();
806
+ let result = format_build_error_with_context(
807
+ "no space left on device",
808
+ &logs,
809
+ &error_logs,
810
+ &buildkit_logs,
811
+ );
469
812
 
470
813
  assert!(result.contains("Free up disk space"));
471
814
  }
@@ -480,7 +823,13 @@ mod tests {
480
823
  error_logs.push_back("error: failed to compile dust".to_string());
481
824
  error_logs.push_back("error: failed to compile glow".to_string());
482
825
 
483
- let result = format_build_error_with_context("Build failed", &recent_logs, &error_logs);
826
+ let buildkit_logs = VecDeque::new();
827
+ let result = format_build_error_with_context(
828
+ "Build failed",
829
+ &recent_logs,
830
+ &error_logs,
831
+ &buildkit_logs,
832
+ );
484
833
 
485
834
  assert!(result.contains("Potential errors detected during build:"));
486
835
  assert!(result.contains("failed to compile dust"));