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