1use std::collections::HashMap;
8use std::path::Path;
9use std::sync::mpsc;
10
11use tracing::{debug, info};
12
13use crate::buildah::{BuildahCommand, BuildahExecutor};
14use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
15use crate::dockerfile::{Dockerfile, ImageRef, Instruction, RunMount, Stage};
16use crate::error::{BuildError, Result};
17use crate::tui::BuildEvent;
18
19use super::BuildBackend;
20
21#[derive(Debug, Default)]
31struct LayerCacheTracker {
32 known_layers: HashMap<(String, String), bool>,
34}
35
36impl LayerCacheTracker {
37 fn new() -> Self {
38 Self::default()
39 }
40
41 #[allow(dead_code)]
42 fn is_cached(&self, instruction_key: &str, base_layer: &str) -> bool {
43 self.known_layers
44 .get(&(instruction_key.to_string(), base_layer.to_string()))
45 .copied()
46 .unwrap_or(false)
47 }
48
49 fn record(&mut self, instruction_key: String, base_layer: String, cached: bool) {
50 self.known_layers
51 .insert((instruction_key, base_layer), cached);
52 }
53
54 #[allow(dead_code, clippy::unused_self)]
55 fn detect_cache_hit(
56 &self,
57 _instruction: &Instruction,
58 _execution_time_ms: u64,
59 _output: &str,
60 ) -> bool {
61 false
63 }
64}
65
66pub struct BuildahBackend {
72 executor: BuildahExecutor,
73}
74
75impl BuildahBackend {
76 pub async fn try_new() -> Result<Self> {
84 let executor = BuildahExecutor::new_async().await?;
85 if !executor.is_available().await {
86 return Err(crate::error::BuildError::BuildahNotFound {
87 message: "buildah is installed but not responding".into(),
88 });
89 }
90 Ok(Self { executor })
91 }
92
93 pub async fn new() -> Result<Self> {
99 let executor = BuildahExecutor::new_async().await?;
100 Ok(Self { executor })
101 }
102
103 #[must_use]
105 pub fn with_executor(executor: BuildahExecutor) -> Self {
106 Self { executor }
107 }
108
109 #[must_use]
111 pub fn executor(&self) -> &BuildahExecutor {
112 &self.executor
113 }
114
115 #[allow(clippy::unused_self)]
121 fn resolve_stages<'a>(
122 &self,
123 dockerfile: &'a Dockerfile,
124 target: Option<&str>,
125 ) -> Result<Vec<&'a Stage>> {
126 if let Some(target) = target {
127 Self::resolve_target_stages(dockerfile, target)
128 } else {
129 Ok(dockerfile.stages.iter().collect())
130 }
131 }
132
133 fn resolve_target_stages<'a>(
135 dockerfile: &'a Dockerfile,
136 target: &str,
137 ) -> Result<Vec<&'a Stage>> {
138 let target_stage = dockerfile
139 .get_stage(target)
140 .ok_or_else(|| BuildError::stage_not_found(target))?;
141
142 let mut stages: Vec<&Stage> = Vec::new();
143 for stage in &dockerfile.stages {
144 stages.push(stage);
145 if stage.index == target_stage.index {
146 break;
147 }
148 }
149 Ok(stages)
150 }
151
152 async fn resolve_base_image(
158 &self,
159 image_ref: &ImageRef,
160 stage_images: &HashMap<String, String>,
161 options: &BuildOptions,
162 ) -> Result<String> {
163 match image_ref {
164 ImageRef::Stage(name) => {
165 return stage_images
166 .get(name)
167 .cloned()
168 .ok_or_else(|| BuildError::stage_not_found(name));
169 }
170 ImageRef::Scratch => return Ok("scratch".to_string()),
171 ImageRef::Registry { .. } => {}
172 }
173
174 let is_qualified = match image_ref {
176 ImageRef::Registry { image, .. } => {
177 let first = image.split('/').next().unwrap_or("");
178 first.contains('.') || first.contains(':') || first == "localhost"
179 }
180 _ => false,
181 };
182
183 if !is_qualified {
185 if let Some(resolved) = self.try_resolve_from_sources(image_ref, options).await {
186 return Ok(resolved);
187 }
188 }
189
190 let qualified = image_ref.qualify();
192 match &qualified {
193 ImageRef::Registry { image, tag, digest } => {
194 let mut result = image.clone();
195 if let Some(t) = tag {
196 result.push(':');
197 result.push_str(t);
198 }
199 if let Some(d) = digest {
200 result.push('@');
201 result.push_str(d);
202 }
203 if tag.is_none() && digest.is_none() {
204 result.push_str(":latest");
205 }
206 Ok(result)
207 }
208 _ => unreachable!("qualify() preserves Registry variant"),
209 }
210 }
211
212 #[allow(clippy::unused_async)]
216 async fn try_resolve_from_sources(
217 &self,
218 image_ref: &ImageRef,
219 options: &BuildOptions,
220 ) -> Option<String> {
221 let (name, tag_str) = match image_ref {
222 ImageRef::Registry { image, tag, .. } => {
223 (image.as_str(), tag.as_deref().unwrap_or("latest"))
224 }
225 _ => return None,
226 };
227
228 if let Some(ref registry) = options.default_registry {
230 let qualified = format!("{registry}/{name}:{tag_str}");
231 debug!("Checking default registry for image: {}", qualified);
232 return Some(qualified);
233 }
234
235 None
236 }
237
238 async fn create_container(&self, image: &str, platform: Option<&str>) -> Result<String> {
240 let cmd = BuildahCommand::new("from")
241 .arg_opt("--platform", platform)
242 .arg(image);
243 let output = self.executor.execute_checked(&cmd).await?;
244 Ok(output.stdout.trim().to_string())
245 }
246
247 async fn commit_container(
249 &self,
250 container: &str,
251 image_name: &str,
252 format: Option<&str>,
253 squash: bool,
254 ) -> Result<String> {
255 let cmd = BuildahCommand::commit_with_opts(container, image_name, format, squash);
256 let output = self.executor.execute_checked(&cmd).await?;
257 Ok(output.stdout.trim().to_string())
258 }
259
260 async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
262 let cmd = BuildahCommand::tag(image, tag);
263 self.executor.execute_checked(&cmd).await?;
264 Ok(())
265 }
266
267 async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
269 let mut cmd = BuildahCommand::push(tag);
270 if let Some(auth) = auth {
271 cmd = cmd
272 .arg("--creds")
273 .arg(format!("{}:{}", auth.username, auth.password));
274 }
275 self.executor.execute_checked(&cmd).await?;
276 Ok(())
277 }
278
279 fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
281 if let Some(tx) = event_tx {
282 let _ = tx.send(event);
283 }
284 }
285}
286
287#[async_trait::async_trait]
288impl BuildBackend for BuildahBackend {
289 #[allow(clippy::too_many_lines)]
290 async fn build_image(
291 &self,
292 _context: &Path,
293 dockerfile: &Dockerfile,
294 options: &BuildOptions,
295 event_tx: Option<mpsc::Sender<BuildEvent>>,
296 ) -> Result<BuiltImage> {
297 let start_time = std::time::Instant::now();
298 let build_id = generate_build_id();
299
300 debug!(
301 "BuildahBackend: starting build (build_id: {}, {} stages)",
302 build_id,
303 dockerfile.stages.len()
304 );
305
306 let stages = self.resolve_stages(dockerfile, options.target.as_deref())?;
308 debug!("Building {} stages", stages.len());
309
310 let mut stage_images: HashMap<String, String> = HashMap::new();
312 let mut stage_workdirs: HashMap<String, String> = HashMap::new();
315 let mut final_container: Option<String> = None;
316 let mut total_instructions = 0;
317
318 let mut cache_tracker = LayerCacheTracker::new();
320
321 for (stage_idx, stage) in stages.iter().enumerate() {
322 let is_final_stage = stage_idx == stages.len() - 1;
323
324 Self::send_event(
325 event_tx.as_ref(),
326 BuildEvent::StageStarted {
327 index: stage_idx,
328 name: stage.name.clone(),
329 base_image: stage.base_image.to_string_ref(),
330 },
331 );
332
333 let base = self
335 .resolve_base_image(&stage.base_image, &stage_images, options)
336 .await?;
337 let container_id = self
338 .create_container(&base, options.platform.as_deref())
339 .await?;
340
341 debug!(
342 "Created container {} for stage {} (base: {})",
343 container_id,
344 stage.identifier(),
345 base
346 );
347
348 let mut current_base_layer = container_id.clone();
350
351 let mut current_workdir = match &stage.base_image {
353 ImageRef::Stage(name) => stage_workdirs
354 .get(name)
355 .cloned()
356 .unwrap_or_else(|| String::from("/")),
357 _ => String::from("/"),
358 };
359
360 for (inst_idx, instruction) in stage.instructions.iter().enumerate() {
362 Self::send_event(
363 event_tx.as_ref(),
364 BuildEvent::InstructionStarted {
365 stage: stage_idx,
366 index: inst_idx,
367 instruction: format!("{instruction:?}"),
368 },
369 );
370
371 let instruction_cache_key = instruction.cache_key();
372 let instruction_start = std::time::Instant::now();
373
374 let resolved_instruction;
377 let instruction_ref = if let Instruction::Copy(copy) = instruction {
378 if let Some(ref from) = copy.from {
379 if let Some(image_name) = stage_images.get(from) {
380 let mut resolved_copy = copy.clone();
381 resolved_copy.from = Some(image_name.clone());
382
383 if let Some(source_workdir) = stage_workdirs.get(from) {
385 resolved_copy.sources = resolved_copy
386 .sources
387 .iter()
388 .map(|src| {
389 if src.starts_with('/') {
390 src.clone()
391 } else if source_workdir == "/" {
392 format!("/{src}")
393 } else {
394 format!("{source_workdir}/{src}")
395 }
396 })
397 .collect();
398 }
399
400 resolved_instruction = Instruction::Copy(resolved_copy);
401 &resolved_instruction
402 } else {
403 instruction
404 }
405 } else {
406 instruction
407 }
408 } else {
409 instruction
410 };
411
412 let instruction_with_defaults;
414 let instruction_ref = if options.default_cache_mounts.is_empty() {
415 instruction_ref
416 } else if let Instruction::Run(run) = instruction_ref {
417 let mut merged = run.clone();
418 for default_mount in &options.default_cache_mounts {
419 let RunMount::Cache { target, .. } = default_mount else {
420 continue;
421 };
422 let already_has = merged
423 .mounts
424 .iter()
425 .any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
426 if !already_has {
427 merged.mounts.push(default_mount.clone());
428 }
429 }
430 instruction_with_defaults = Instruction::Run(merged);
431 &instruction_with_defaults
432 } else {
433 instruction_ref
434 };
435
436 let is_run_instruction = matches!(instruction_ref, Instruction::Run(_));
437 let max_attempts = if is_run_instruction {
438 options.retries + 1
439 } else {
440 1
441 };
442
443 let commands = BuildahCommand::from_instruction(&container_id, instruction_ref);
444
445 let mut combined_output = String::new();
446 for cmd in commands {
447 let mut last_output = None;
448
449 for attempt in 1..=max_attempts {
450 if attempt > 1 {
451 tracing::warn!(
452 "Retrying step (attempt {}/{})...",
453 attempt,
454 max_attempts
455 );
456 Self::send_event(
457 event_tx.as_ref(),
458 BuildEvent::Output {
459 line: format!(
460 "⟳ Retrying step (attempt {attempt}/{max_attempts})..."
461 ),
462 is_stderr: false,
463 },
464 );
465 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
466 }
467
468 let event_tx_clone = event_tx.clone();
469 let output = self
470 .executor
471 .execute_streaming(&cmd, |is_stdout, line| {
472 Self::send_event(
473 event_tx_clone.as_ref(),
474 BuildEvent::Output {
475 line: line.to_string(),
476 is_stderr: !is_stdout,
477 },
478 );
479 })
480 .await?;
481
482 combined_output.push_str(&output.stdout);
483 combined_output.push_str(&output.stderr);
484
485 if output.success() {
486 last_output = Some(output);
487 break;
488 }
489
490 last_output = Some(output);
491 }
492
493 let output = last_output.unwrap();
494 if !output.success() {
495 Self::send_event(
496 event_tx.as_ref(),
497 BuildEvent::BuildFailed {
498 error: output.stderr.clone(),
499 },
500 );
501
502 let _ = self
504 .executor
505 .execute(&BuildahCommand::rm(&container_id))
506 .await;
507
508 return Err(BuildError::buildah_execution(
509 cmd.to_command_string(),
510 output.exit_code,
511 output.stderr,
512 ));
513 }
514 }
515
516 #[allow(clippy::cast_possible_truncation)]
517 let instruction_elapsed_ms = instruction_start.elapsed().as_millis() as u64;
518
519 if let Instruction::Workdir(dir) = instruction {
521 current_workdir.clone_from(dir);
522 }
523
524 let cached = cache_tracker.detect_cache_hit(
526 instruction,
527 instruction_elapsed_ms,
528 &combined_output,
529 );
530
531 cache_tracker.record(
532 instruction_cache_key.clone(),
533 current_base_layer.clone(),
534 cached,
535 );
536
537 current_base_layer = format!("{current_base_layer}:{instruction_cache_key}");
538
539 Self::send_event(
540 event_tx.as_ref(),
541 BuildEvent::InstructionComplete {
542 stage: stage_idx,
543 index: inst_idx,
544 cached,
545 },
546 );
547
548 total_instructions += 1;
549 }
550
551 if let Some(name) = &stage.name {
553 let image_name = format!("zlayer-build-{build_id}-stage-{name}");
554 self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
555 .await?;
556 stage_images.insert(name.clone(), image_name.clone());
557 stage_workdirs.insert(name.clone(), current_workdir.clone());
558
559 stage_images.insert(stage.index.to_string(), image_name.clone());
561 stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
562
563 if is_final_stage {
564 final_container = Some(container_id);
565 } else {
566 let _ = self
567 .executor
568 .execute(&BuildahCommand::rm(&container_id))
569 .await;
570 }
571 } else if is_final_stage {
572 final_container = Some(container_id);
573 } else {
574 let image_name = format!("zlayer-build-{}-stage-{}", build_id, stage.index);
575 self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
576 .await?;
577 stage_images.insert(stage.index.to_string(), image_name);
578 stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
579 let _ = self
580 .executor
581 .execute(&BuildahCommand::rm(&container_id))
582 .await;
583 }
584
585 Self::send_event(
586 event_tx.as_ref(),
587 BuildEvent::StageComplete { index: stage_idx },
588 );
589 }
590
591 let final_container = final_container.ok_or_else(|| BuildError::InvalidInstruction {
593 instruction: "build".to_string(),
594 reason: "No stages to build".to_string(),
595 })?;
596
597 let image_name = options
598 .tags
599 .first()
600 .cloned()
601 .unwrap_or_else(|| format!("zlayer-build:{}", chrono_lite_timestamp()));
602
603 let image_id = self
604 .commit_container(
605 &final_container,
606 &image_name,
607 options.format.as_deref(),
608 options.squash,
609 )
610 .await?;
611
612 info!("Committed final image: {} ({})", image_name, image_id);
613
614 for tag in options.tags.iter().skip(1) {
616 self.tag_image_internal(&image_id, tag).await?;
617 debug!("Applied tag: {}", tag);
618 }
619
620 let _ = self
622 .executor
623 .execute(&BuildahCommand::rm(&final_container))
624 .await;
625
626 for (_, img) in stage_images {
628 let _ = self.executor.execute(&BuildahCommand::rmi(&img)).await;
629 }
630
631 if options.push {
633 for tag in &options.tags {
634 self.push_image_internal(tag, options.registry_auth.as_ref())
635 .await?;
636 info!("Pushed image: {}", tag);
637 }
638 }
639
640 #[allow(clippy::cast_possible_truncation)]
641 let build_time_ms = start_time.elapsed().as_millis() as u64;
642
643 Self::send_event(
644 event_tx.as_ref(),
645 BuildEvent::BuildComplete {
646 image_id: image_id.clone(),
647 },
648 );
649
650 info!(
651 "Build completed in {}ms: {} with {} tags",
652 build_time_ms,
653 image_id,
654 options.tags.len()
655 );
656
657 Ok(BuiltImage {
658 image_id,
659 tags: options.tags.clone(),
660 layer_count: total_instructions,
661 size: 0, build_time_ms,
663 is_manifest: false,
664 })
665 }
666
667 async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
668 self.push_image_internal(tag, auth).await
669 }
670
671 async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
672 self.tag_image_internal(image, new_tag).await
673 }
674
675 async fn manifest_create(&self, name: &str) -> Result<()> {
676 let cmd = BuildahCommand::manifest_create(name);
677 self.executor.execute_checked(&cmd).await?;
678 Ok(())
679 }
680
681 async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
682 let cmd = BuildahCommand::manifest_add(manifest, image);
683 self.executor.execute_checked(&cmd).await?;
684 Ok(())
685 }
686
687 async fn manifest_push(&self, name: &str, destination: &str) -> Result<()> {
688 let cmd = BuildahCommand::manifest_push(name, destination);
689 self.executor.execute_checked(&cmd).await?;
690 Ok(())
691 }
692
693 async fn is_available(&self) -> bool {
694 self.executor.is_available().await
695 }
696
697 fn name(&self) -> &'static str {
698 "buildah"
699 }
700}
701
702fn chrono_lite_timestamp() -> String {
707 use std::time::{SystemTime, UNIX_EPOCH};
708 let duration = SystemTime::now()
709 .duration_since(UNIX_EPOCH)
710 .unwrap_or_default();
711 format!("{}", duration.as_secs())
712}
713
714fn generate_build_id() -> String {
716 use sha2::{Digest, Sha256};
717 use std::time::{SystemTime, UNIX_EPOCH};
718
719 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
720
721 let nanos = SystemTime::now()
722 .duration_since(UNIX_EPOCH)
723 .unwrap_or_default()
724 .as_nanos();
725 let pid = std::process::id();
726 let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
727
728 let mut hasher = Sha256::new();
729 hasher.update(nanos.to_le_bytes());
730 hasher.update(pid.to_le_bytes());
731 hasher.update(count.to_le_bytes());
732 let hash = hasher.finalize();
733 hex::encode(&hash[..6])
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn test_layer_cache_tracker_new() {
742 let tracker = LayerCacheTracker::new();
743 assert!(tracker.known_layers.is_empty());
744 }
745
746 #[test]
747 fn test_layer_cache_tracker_record_and_lookup() {
748 let mut tracker = LayerCacheTracker::new();
749
750 tracker.record("abc123".to_string(), "container-1".to_string(), false);
751 assert!(!tracker.is_cached("abc123", "container-1"));
752
753 tracker.record("def456".to_string(), "container-2".to_string(), true);
754 assert!(tracker.is_cached("def456", "container-2"));
755 }
756
757 #[test]
758 fn test_layer_cache_tracker_unknown_returns_false() {
759 let tracker = LayerCacheTracker::new();
760 assert!(!tracker.is_cached("unknown", "unknown"));
761 }
762
763 #[test]
764 fn test_layer_cache_tracker_different_base_layers() {
765 let mut tracker = LayerCacheTracker::new();
766
767 tracker.record("inst-1".to_string(), "base-a".to_string(), true);
768 tracker.record("inst-1".to_string(), "base-b".to_string(), false);
769
770 assert!(tracker.is_cached("inst-1", "base-a"));
771 assert!(!tracker.is_cached("inst-1", "base-b"));
772 }
773
774 #[test]
775 fn test_layer_cache_tracker_detect_cache_hit() {
776 use crate::dockerfile::RunInstruction;
777
778 let tracker = LayerCacheTracker::new();
779 let instruction = Instruction::Run(RunInstruction::shell("echo hello"));
780
781 assert!(!tracker.detect_cache_hit(&instruction, 50, ""));
782 assert!(!tracker.detect_cache_hit(&instruction, 1000, ""));
783 assert!(!tracker.detect_cache_hit(&instruction, 50, "Using cache"));
784 }
785
786 #[test]
787 fn test_layer_cache_tracker_overwrite() {
788 let mut tracker = LayerCacheTracker::new();
789
790 tracker.record("key".to_string(), "base".to_string(), false);
791 assert!(!tracker.is_cached("key", "base"));
792
793 tracker.record("key".to_string(), "base".to_string(), true);
794 assert!(tracker.is_cached("key", "base"));
795 }
796}