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, PullBaseMode, 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(
240 &self,
241 image: &str,
242 platform: Option<&str>,
243 pull: PullBaseMode,
244 ) -> Result<String> {
245 let mut cmd = BuildahCommand::new("from").arg_opt("--platform", platform);
246
247 match pull {
248 PullBaseMode::Newer => cmd = cmd.arg("--pull=newer"),
249 PullBaseMode::Always => cmd = cmd.arg("--pull=always"),
250 PullBaseMode::Never => { }
251 }
252
253 cmd = cmd.arg(image);
254
255 let output = self.executor.execute_checked(&cmd).await?;
256 Ok(output.stdout.trim().to_string())
257 }
258
259 async fn commit_container(
261 &self,
262 container: &str,
263 image_name: &str,
264 format: Option<&str>,
265 squash: bool,
266 ) -> Result<String> {
267 let cmd = BuildahCommand::commit_with_opts(container, image_name, format, squash);
268 let output = self.executor.execute_checked(&cmd).await?;
269 Ok(output.stdout.trim().to_string())
270 }
271
272 async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
274 let cmd = BuildahCommand::tag(image, tag);
275 self.executor.execute_checked(&cmd).await?;
276 Ok(())
277 }
278
279 async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
281 let mut cmd = BuildahCommand::push(tag);
282 if let Some(auth) = auth {
283 cmd = cmd
284 .arg("--creds")
285 .arg(format!("{}:{}", auth.username, auth.password));
286 }
287 self.executor.execute_checked(&cmd).await?;
288 Ok(())
289 }
290
291 fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
293 if let Some(tx) = event_tx {
294 let _ = tx.send(event);
295 }
296 }
297}
298
299#[async_trait::async_trait]
300impl BuildBackend for BuildahBackend {
301 #[allow(clippy::too_many_lines)]
302 async fn build_image(
303 &self,
304 _context: &Path,
305 dockerfile: &Dockerfile,
306 options: &BuildOptions,
307 event_tx: Option<mpsc::Sender<BuildEvent>>,
308 ) -> Result<BuiltImage> {
309 let start_time = std::time::Instant::now();
310 let build_id = generate_build_id();
311
312 debug!(
313 "BuildahBackend: starting build (build_id: {}, {} stages)",
314 build_id,
315 dockerfile.stages.len()
316 );
317
318 let stages = self.resolve_stages(dockerfile, options.target.as_deref())?;
320 debug!("Building {} stages", stages.len());
321
322 let total_instructions_planned: usize = stages.iter().map(|s| s.instructions.len()).sum();
326 Self::send_event(
327 event_tx.as_ref(),
328 BuildEvent::BuildStarted {
329 total_stages: stages.len(),
330 total_instructions: total_instructions_planned,
331 },
332 );
333
334 let mut stage_images: HashMap<String, String> = HashMap::new();
336 let mut stage_workdirs: HashMap<String, String> = HashMap::new();
339 let mut final_container: Option<String> = None;
340 let mut total_instructions = 0;
341
342 let mut cache_tracker = LayerCacheTracker::new();
344
345 for (stage_idx, stage) in stages.iter().enumerate() {
346 let is_final_stage = stage_idx == stages.len() - 1;
347
348 Self::send_event(
349 event_tx.as_ref(),
350 BuildEvent::StageStarted {
351 index: stage_idx,
352 name: stage.name.clone(),
353 base_image: stage.base_image.to_string_ref(),
354 },
355 );
356
357 let base = self
359 .resolve_base_image(&stage.base_image, &stage_images, options)
360 .await?;
361 let container_id = self
362 .create_container(&base, options.platform.as_deref(), options.pull)
363 .await?;
364
365 debug!(
366 "Created container {} for stage {} (base: {})",
367 container_id,
368 stage.identifier(),
369 base
370 );
371
372 let mut current_base_layer = container_id.clone();
374
375 let mut current_workdir = match &stage.base_image {
377 ImageRef::Stage(name) => stage_workdirs
378 .get(name)
379 .cloned()
380 .unwrap_or_else(|| String::from("/")),
381 _ => String::from("/"),
382 };
383
384 for (inst_idx, instruction) in stage.instructions.iter().enumerate() {
386 Self::send_event(
387 event_tx.as_ref(),
388 BuildEvent::InstructionStarted {
389 stage: stage_idx,
390 index: inst_idx,
391 instruction: format!("{instruction:?}"),
392 },
393 );
394
395 let instruction_cache_key = instruction.cache_key();
396 let instruction_start = std::time::Instant::now();
397
398 let resolved_instruction;
401 let instruction_ref = if let Instruction::Copy(copy) = instruction {
402 if let Some(ref from) = copy.from {
403 if let Some(image_name) = stage_images.get(from) {
404 let mut resolved_copy = copy.clone();
405 resolved_copy.from = Some(image_name.clone());
406
407 if let Some(source_workdir) = stage_workdirs.get(from) {
409 resolved_copy.sources = resolved_copy
410 .sources
411 .iter()
412 .map(|src| {
413 if src.starts_with('/') {
414 src.clone()
415 } else if source_workdir == "/" {
416 format!("/{src}")
417 } else {
418 format!("{source_workdir}/{src}")
419 }
420 })
421 .collect();
422 }
423
424 resolved_instruction = Instruction::Copy(resolved_copy);
425 &resolved_instruction
426 } else {
427 instruction
428 }
429 } else {
430 instruction
431 }
432 } else {
433 instruction
434 };
435
436 let instruction_with_defaults;
438 let instruction_ref = if options.default_cache_mounts.is_empty() {
439 instruction_ref
440 } else if let Instruction::Run(run) = instruction_ref {
441 let mut merged = run.clone();
442 for default_mount in &options.default_cache_mounts {
443 let RunMount::Cache { target, .. } = default_mount else {
444 continue;
445 };
446 let already_has = merged
447 .mounts
448 .iter()
449 .any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
450 if !already_has {
451 merged.mounts.push(default_mount.clone());
452 }
453 }
454 instruction_with_defaults = Instruction::Run(merged);
455 &instruction_with_defaults
456 } else {
457 instruction_ref
458 };
459
460 let is_run_instruction = matches!(instruction_ref, Instruction::Run(_));
461 let max_attempts = if is_run_instruction {
462 options.retries + 1
463 } else {
464 1
465 };
466
467 let commands = BuildahCommand::from_instruction(&container_id, instruction_ref);
468
469 let mut combined_output = String::new();
470 for cmd in commands {
471 let mut last_output = None;
472
473 for attempt in 1..=max_attempts {
474 if attempt > 1 {
475 tracing::warn!(
476 "Retrying step (attempt {}/{})...",
477 attempt,
478 max_attempts
479 );
480 Self::send_event(
481 event_tx.as_ref(),
482 BuildEvent::Output {
483 line: format!(
484 "⟳ Retrying step (attempt {attempt}/{max_attempts})..."
485 ),
486 is_stderr: false,
487 },
488 );
489 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
490 }
491
492 let event_tx_clone = event_tx.clone();
493 let output = self
494 .executor
495 .execute_streaming(&cmd, |is_stdout, line| {
496 Self::send_event(
497 event_tx_clone.as_ref(),
498 BuildEvent::Output {
499 line: line.to_string(),
500 is_stderr: !is_stdout,
501 },
502 );
503 })
504 .await?;
505
506 combined_output.push_str(&output.stdout);
507 combined_output.push_str(&output.stderr);
508
509 if output.success() {
510 last_output = Some(output);
511 break;
512 }
513
514 last_output = Some(output);
515 }
516
517 let output = last_output.unwrap();
518 if !output.success() {
519 Self::send_event(
520 event_tx.as_ref(),
521 BuildEvent::BuildFailed {
522 error: output.stderr.clone(),
523 },
524 );
525
526 let _ = self
528 .executor
529 .execute(&BuildahCommand::rm(&container_id))
530 .await;
531
532 return Err(BuildError::buildah_execution(
533 cmd.to_command_string(),
534 output.exit_code,
535 output.stderr,
536 ));
537 }
538 }
539
540 #[allow(clippy::cast_possible_truncation)]
541 let instruction_elapsed_ms = instruction_start.elapsed().as_millis() as u64;
542
543 if let Instruction::Workdir(dir) = instruction {
545 current_workdir.clone_from(dir);
546 }
547
548 let cached = cache_tracker.detect_cache_hit(
550 instruction,
551 instruction_elapsed_ms,
552 &combined_output,
553 );
554
555 cache_tracker.record(
556 instruction_cache_key.clone(),
557 current_base_layer.clone(),
558 cached,
559 );
560
561 current_base_layer = format!("{current_base_layer}:{instruction_cache_key}");
562
563 Self::send_event(
564 event_tx.as_ref(),
565 BuildEvent::InstructionComplete {
566 stage: stage_idx,
567 index: inst_idx,
568 cached,
569 },
570 );
571
572 total_instructions += 1;
573 }
574
575 if let Some(name) = &stage.name {
577 let image_name = format!("zlayer-build-{build_id}-stage-{name}");
578 self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
579 .await?;
580 stage_images.insert(name.clone(), image_name.clone());
581 stage_workdirs.insert(name.clone(), current_workdir.clone());
582
583 stage_images.insert(stage.index.to_string(), image_name.clone());
585 stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
586
587 if is_final_stage {
588 final_container = Some(container_id);
589 } else {
590 let _ = self
591 .executor
592 .execute(&BuildahCommand::rm(&container_id))
593 .await;
594 }
595 } else if is_final_stage {
596 final_container = Some(container_id);
597 } else {
598 let image_name = format!("zlayer-build-{}-stage-{}", build_id, stage.index);
599 self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
600 .await?;
601 stage_images.insert(stage.index.to_string(), image_name);
602 stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
603 let _ = self
604 .executor
605 .execute(&BuildahCommand::rm(&container_id))
606 .await;
607 }
608
609 Self::send_event(
610 event_tx.as_ref(),
611 BuildEvent::StageComplete { index: stage_idx },
612 );
613 }
614
615 let final_container = final_container.ok_or_else(|| BuildError::InvalidInstruction {
617 instruction: "build".to_string(),
618 reason: "No stages to build".to_string(),
619 })?;
620
621 let image_name = options
622 .tags
623 .first()
624 .cloned()
625 .unwrap_or_else(|| format!("zlayer-build:{}", chrono_lite_timestamp()));
626
627 let image_id = self
628 .commit_container(
629 &final_container,
630 &image_name,
631 options.format.as_deref(),
632 options.squash,
633 )
634 .await?;
635
636 info!("Committed final image: {} ({})", image_name, image_id);
637
638 for tag in options.tags.iter().skip(1) {
640 self.tag_image_internal(&image_id, tag).await?;
641 debug!("Applied tag: {}", tag);
642 }
643
644 let _ = self
646 .executor
647 .execute(&BuildahCommand::rm(&final_container))
648 .await;
649
650 for (_, img) in stage_images {
652 let _ = self.executor.execute(&BuildahCommand::rmi(&img)).await;
653 }
654
655 if options.push {
657 for tag in &options.tags {
658 self.push_image_internal(tag, options.registry_auth.as_ref())
659 .await?;
660 info!("Pushed image: {}", tag);
661 }
662 }
663
664 #[allow(clippy::cast_possible_truncation)]
665 let build_time_ms = start_time.elapsed().as_millis() as u64;
666
667 Self::send_event(
668 event_tx.as_ref(),
669 BuildEvent::BuildComplete {
670 image_id: image_id.clone(),
671 },
672 );
673
674 info!(
675 "Build completed in {}ms: {} with {} tags",
676 build_time_ms,
677 image_id,
678 options.tags.len()
679 );
680
681 Ok(BuiltImage {
682 image_id,
683 tags: options.tags.clone(),
684 layer_count: total_instructions,
685 size: 0, build_time_ms,
687 is_manifest: false,
688 })
689 }
690
691 async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
692 self.push_image_internal(tag, auth).await
693 }
694
695 async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
696 self.tag_image_internal(image, new_tag).await
697 }
698
699 async fn manifest_create(&self, name: &str) -> Result<()> {
700 let cmd = BuildahCommand::manifest_create(name);
701 self.executor.execute_checked(&cmd).await?;
702 Ok(())
703 }
704
705 async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
706 let cmd = BuildahCommand::manifest_add(manifest, image);
707 self.executor.execute_checked(&cmd).await?;
708 Ok(())
709 }
710
711 async fn manifest_push(&self, name: &str, destination: &str) -> Result<()> {
712 let cmd = BuildahCommand::manifest_push(name, destination);
713 self.executor.execute_checked(&cmd).await?;
714 Ok(())
715 }
716
717 async fn is_available(&self) -> bool {
718 self.executor.is_available().await
719 }
720
721 fn name(&self) -> &'static str {
722 "buildah"
723 }
724}
725
726fn chrono_lite_timestamp() -> String {
731 use std::time::{SystemTime, UNIX_EPOCH};
732 let duration = SystemTime::now()
733 .duration_since(UNIX_EPOCH)
734 .unwrap_or_default();
735 format!("{}", duration.as_secs())
736}
737
738fn generate_build_id() -> String {
740 use sha2::{Digest, Sha256};
741 use std::time::{SystemTime, UNIX_EPOCH};
742
743 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
744
745 let nanos = SystemTime::now()
746 .duration_since(UNIX_EPOCH)
747 .unwrap_or_default()
748 .as_nanos();
749 let pid = std::process::id();
750 let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
751
752 let mut hasher = Sha256::new();
753 hasher.update(nanos.to_le_bytes());
754 hasher.update(pid.to_le_bytes());
755 hasher.update(count.to_le_bytes());
756 let hash = hasher.finalize();
757 hex::encode(&hash[..6])
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763
764 #[test]
765 fn test_layer_cache_tracker_new() {
766 let tracker = LayerCacheTracker::new();
767 assert!(tracker.known_layers.is_empty());
768 }
769
770 #[test]
771 fn test_layer_cache_tracker_record_and_lookup() {
772 let mut tracker = LayerCacheTracker::new();
773
774 tracker.record("abc123".to_string(), "container-1".to_string(), false);
775 assert!(!tracker.is_cached("abc123", "container-1"));
776
777 tracker.record("def456".to_string(), "container-2".to_string(), true);
778 assert!(tracker.is_cached("def456", "container-2"));
779 }
780
781 #[test]
782 fn test_layer_cache_tracker_unknown_returns_false() {
783 let tracker = LayerCacheTracker::new();
784 assert!(!tracker.is_cached("unknown", "unknown"));
785 }
786
787 #[test]
788 fn test_layer_cache_tracker_different_base_layers() {
789 let mut tracker = LayerCacheTracker::new();
790
791 tracker.record("inst-1".to_string(), "base-a".to_string(), true);
792 tracker.record("inst-1".to_string(), "base-b".to_string(), false);
793
794 assert!(tracker.is_cached("inst-1", "base-a"));
795 assert!(!tracker.is_cached("inst-1", "base-b"));
796 }
797
798 #[test]
799 fn test_layer_cache_tracker_detect_cache_hit() {
800 use crate::dockerfile::RunInstruction;
801
802 let tracker = LayerCacheTracker::new();
803 let instruction = Instruction::Run(RunInstruction::shell("echo hello"));
804
805 assert!(!tracker.detect_cache_hit(&instruction, 50, ""));
806 assert!(!tracker.detect_cache_hit(&instruction, 1000, ""));
807 assert!(!tracker.detect_cache_hit(&instruction, 50, "Using cache"));
808 }
809
810 #[test]
811 fn test_layer_cache_tracker_overwrite() {
812 let mut tracker = LayerCacheTracker::new();
813
814 tracker.record("key".to_string(), "base".to_string(), false);
815 assert!(!tracker.is_cached("key", "base"));
816
817 tracker.record("key".to_string(), "base".to_string(), true);
818 assert!(tracker.is_cached("key", "base"));
819 }
820}