1use std::collections::{HashMap, HashSet};
39use std::path::{Path, PathBuf};
40use std::str::FromStr;
41use std::sync::Arc;
42
43use tokio::sync::Mutex;
44use tokio::task::JoinSet;
45use tracing::{error, info, warn};
46
47use serde::Deserialize;
48
49use crate::backend::{detect_backend, BuildBackend, ImageOs};
50
51#[derive(Deserialize)]
54struct CachedImageConfig {
55 #[serde(default)]
56 source_hash: Option<String>,
57}
58use crate::buildah::{BuildahCommand, BuildahExecutor};
59use crate::builder::{BuiltImage, ImageBuilder};
60use crate::error::{BuildError, Result};
61use zlayer_paths::ZLayerDirs;
62
63use super::types::{PipelineDefaults, PipelineImage, ZPipeline};
64
65#[cfg(feature = "local-registry")]
66use zlayer_registry::LocalRegistry;
67
68#[derive(Debug)]
70pub struct PipelineResult {
71 pub succeeded: HashMap<String, BuiltImage>,
73 pub failed: HashMap<String, String>,
75 pub total_time_ms: u64,
77}
78
79impl PipelineResult {
80 #[must_use]
82 pub fn is_success(&self) -> bool {
83 self.failed.is_empty()
84 }
85
86 #[must_use]
88 pub fn total_images(&self) -> usize {
89 self.succeeded.len() + self.failed.len()
90 }
91}
92
93pub struct PipelineExecutor {
98 pipeline: ZPipeline,
100 base_dir: PathBuf,
102 executor: BuildahExecutor,
104 backend: Option<Arc<dyn BuildBackend>>,
111 backend_cache: Arc<Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>>>,
119 fail_fast: bool,
121 push_enabled: bool,
123 host_network: bool,
128 #[cfg(feature = "local-registry")]
130 local_registry: Option<Arc<LocalRegistry>>,
131}
132
133impl PipelineExecutor {
134 #[must_use]
142 pub fn new(pipeline: ZPipeline, base_dir: PathBuf, executor: BuildahExecutor) -> Self {
143 let push_enabled = pipeline.push.after_all;
145
146 Self {
147 pipeline,
148 base_dir,
149 executor,
150 backend: None,
151 backend_cache: Arc::new(Mutex::new(HashMap::new())),
152 fail_fast: true,
153 push_enabled,
154 host_network: false,
155 #[cfg(feature = "local-registry")]
156 local_registry: None,
157 }
158 }
159
160 #[must_use]
172 pub fn with_backend(
173 pipeline: ZPipeline,
174 base_dir: PathBuf,
175 backend: Arc<dyn BuildBackend>,
176 ) -> Self {
177 let push_enabled = pipeline.push.after_all;
178
179 Self {
180 pipeline,
181 base_dir,
182 executor: BuildahExecutor::default(),
183 backend: Some(backend),
184 backend_cache: Arc::new(Mutex::new(HashMap::new())),
185 fail_fast: true,
186 push_enabled,
187 host_network: false,
188 #[cfg(feature = "local-registry")]
189 local_registry: None,
190 }
191 }
192
193 #[must_use]
199 pub fn fail_fast(mut self, fail_fast: bool) -> Self {
200 self.fail_fast = fail_fast;
201 self
202 }
203
204 #[must_use]
209 pub fn push(mut self, enabled: bool) -> Self {
210 self.push_enabled = enabled;
211 self
212 }
213
214 #[must_use]
219 pub fn with_host_network(mut self, on: bool) -> Self {
220 self.host_network = on;
221 self
222 }
223
224 #[cfg(feature = "local-registry")]
230 #[must_use]
231 pub fn with_local_registry(mut self, registry: Arc<LocalRegistry>) -> Self {
232 self.local_registry = Some(registry);
233 self
234 }
235
236 fn resolve_execution_order(&self) -> Result<Vec<Vec<String>>> {
248 let mut waves: Vec<Vec<String>> = Vec::new();
249 let mut assigned: HashSet<String> = HashSet::new();
250 let mut remaining: HashSet<String> = self.pipeline.images.keys().cloned().collect();
251
252 for (name, image) in &self.pipeline.images {
254 for dep in &image.depends_on {
255 if !self.pipeline.images.contains_key(dep) {
256 return Err(BuildError::invalid_instruction(
257 "pipeline",
258 format!("Image '{name}' depends on unknown image '{dep}'"),
259 ));
260 }
261 }
262 }
263
264 while !remaining.is_empty() {
266 let mut wave: Vec<String> = Vec::new();
267
268 for name in &remaining {
269 let image = &self.pipeline.images[name];
270 let deps_satisfied = image.depends_on.iter().all(|d| assigned.contains(d));
272 if deps_satisfied {
273 wave.push(name.clone());
274 }
275 }
276
277 if wave.is_empty() {
278 return Err(BuildError::CircularDependency {
280 stages: remaining.into_iter().collect(),
281 });
282 }
283
284 for name in &wave {
286 remaining.remove(name);
287 assigned.insert(name.clone());
288 }
289
290 waves.push(wave);
291 }
292
293 Ok(waves)
294 }
295
296 pub async fn run(&self) -> Result<PipelineResult> {
311 let start = std::time::Instant::now();
312 let waves = self.resolve_execution_order()?;
313
314 let mut succeeded: HashMap<String, BuiltImage> = HashMap::new();
315 let mut failed: HashMap<String, String> = HashMap::new();
316
317 info!(
318 "Building {} images in {} waves",
319 self.pipeline.images.len(),
320 waves.len()
321 );
322
323 for (wave_idx, wave) in waves.iter().enumerate() {
324 info!("Wave {}: {:?}", wave_idx, wave);
325
326 if self.fail_fast && !failed.is_empty() {
328 warn!("Aborting pipeline due to previous failures (fail_fast enabled)");
329 break;
330 }
331
332 let wave_results = self.build_wave(wave).await;
334
335 for (name, result) in wave_results {
337 match result {
338 Ok(image) => {
339 info!("[{}] Build succeeded: {}", name, image.image_id);
340 succeeded.insert(name, image);
341 }
342 Err(e) => {
343 error!("[{}] Build failed: {}", name, e);
344 failed.insert(name.clone(), e.to_string());
345
346 if self.fail_fast {
347 return Err(e);
349 }
350 }
351 }
352 }
353 }
354
355 if self.push_enabled && failed.is_empty() {
357 info!("Pushing {} images", succeeded.len());
358
359 for (name, image) in &succeeded {
360 let backend = match self.backend_for_image(name).await {
365 Ok(b) => b,
366 Err(e) => {
367 warn!("[{}] Failed to resolve push backend: {}", name, e);
368 continue;
369 }
370 };
371
372 if image.tags.len() > 1 {
376 let first = &image.tags[0];
377 for secondary in &image.tags[1..] {
378 if let Err(e) = backend.tag_image(first, secondary).await {
379 warn!("Failed to tag {} as {}: {}", first, secondary, e);
380 }
381 }
382 }
383
384 for tag in &image.tags {
385 let push_result = if image.is_manifest {
386 self.push_manifest(&backend, tag).await
387 } else {
388 self.push_image(&backend, tag).await
389 };
390
391 if let Err(e) = push_result {
392 warn!("[{}] Failed to push {}: {}", name, tag, e);
393 } else {
396 info!("[{}] Pushed: {}", name, tag);
397 }
398 }
399 }
400 }
401
402 #[allow(clippy::cast_possible_truncation)]
403 let total_time_ms = start.elapsed().as_millis() as u64;
404
405 Ok(PipelineResult {
406 succeeded,
407 failed,
408 total_time_ms,
409 })
410 }
411
412 async fn build_wave(&self, wave: &[String]) -> Vec<(String, Result<BuiltImage>)> {
432 let pipeline = Arc::new(self.pipeline.clone());
434 let base_dir = Arc::new(self.base_dir.clone());
435 let executor = self.executor.clone();
436 let explicit_backend = self.backend.clone();
437 let backend_cache = Arc::clone(&self.backend_cache);
438 let host_network = self.host_network;
439
440 #[cfg(feature = "local-registry")]
443 let registry_root: Option<PathBuf> =
444 self.local_registry.as_ref().map(|r| r.root().to_path_buf());
445 #[cfg(not(feature = "local-registry"))]
446 let registry_root: Option<PathBuf> = None;
447
448 let mut set = JoinSet::new();
449
450 for name in wave {
451 let name = name.clone();
452 let pipeline = Arc::clone(&pipeline);
453 let base_dir = Arc::clone(&base_dir);
454 let executor = executor.clone();
455 let explicit_backend = explicit_backend.clone();
456 let backend_cache = Arc::clone(&backend_cache);
457 let registry_root = registry_root.clone();
458
459 set.spawn(async move {
460 let result = build_one_image(
461 &name,
462 &pipeline,
463 &base_dir,
464 executor,
465 explicit_backend,
466 &backend_cache,
467 registry_root.as_deref(),
468 host_network,
469 )
470 .await;
471 (name, result)
472 });
473 }
474
475 let mut results = Vec::new();
477 while let Some(join_result) = set.join_next().await {
478 match join_result {
479 Ok((name, result)) => {
480 results.push((name, result));
481 }
482 Err(e) => {
483 error!("Build task panicked: {}", e);
485 results.push((
486 "unknown".to_string(),
487 Err(BuildError::invalid_instruction(
488 "pipeline",
489 format!("Build task panicked: {e}"),
490 )),
491 ));
492 }
493 }
494 }
495
496 results
497 }
498
499 async fn backend_for_image(&self, name: &str) -> Result<Arc<dyn BuildBackend>> {
503 let image_config = &self.pipeline.images[name];
504 let platforms = effective_platforms(image_config, &self.pipeline.defaults);
505 let target_os = target_os_for_image(&platforms, image_config.os)?;
506 backend_for(target_os, &self.backend_cache, self.backend.clone()).await
507 }
508
509 async fn push_image(&self, backend: &Arc<dyn BuildBackend>, tag: &str) -> Result<()> {
511 backend.push_image(tag, None).await
512 }
513
514 async fn push_manifest(&self, backend: &Arc<dyn BuildBackend>, tag: &str) -> Result<()> {
516 let destination = format!("docker://{tag}");
517 backend.manifest_push(tag, &destination, None).await
518 }
519}
520
521fn effective_platforms(image: &PipelineImage, defaults: &PipelineDefaults) -> Vec<String> {
527 if image.platforms.is_empty() {
528 defaults.platforms.clone()
529 } else {
530 image.platforms.clone()
531 }
532}
533
534fn target_os_for_image(platforms: &[String], explicit_os: Option<ImageOs>) -> Result<ImageOs> {
548 let mut selected: Option<ImageOs> = None;
551 for platform in platforms {
552 let os = ImageOs::from_str(platform).map_err(|e| {
553 BuildError::invalid_instruction(
554 "pipeline",
555 format!("unrecognized platform '{platform}': {e}"),
556 )
557 })?;
558 match selected {
559 None => selected = Some(os),
560 Some(existing) if existing == os => {}
561 Some(existing) => {
562 return Err(BuildError::invalid_instruction(
563 "pipeline",
564 format!(
565 "multi-platform images cannot mix OSes in a single entry \
566 (found {existing:?} and {os:?} in platforms={platforms:?}); \
567 split into separate PipelineImage entries"
568 ),
569 ));
570 }
571 }
572 }
573
574 if let Some(explicit) = explicit_os {
575 if let Some(from_platforms) = selected {
576 if from_platforms != explicit {
577 return Err(BuildError::invalid_instruction(
578 "pipeline",
579 format!(
580 "explicit os={explicit:?} conflicts with OS inferred from \
581 platforms={platforms:?} (got {from_platforms:?}); remove one \
582 or make them agree"
583 ),
584 ));
585 }
586 }
587 return Ok(explicit);
588 }
589
590 Ok(selected.unwrap_or(ImageOs::Linux))
591}
592
593async fn backend_for(
602 target_os: ImageOs,
603 cache: &Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>>,
604 explicit: Option<Arc<dyn BuildBackend>>,
605) -> Result<Arc<dyn BuildBackend>> {
606 if let Some(backend) = explicit {
607 return Ok(backend);
608 }
609
610 {
612 let guard = cache.lock().await;
613 if let Some(backend) = guard.get(&target_os) {
614 return Ok(Arc::clone(backend));
615 }
616 }
617
618 let mut guard = cache.lock().await;
621 if let Some(backend) = guard.get(&target_os) {
622 return Ok(Arc::clone(backend));
623 }
624 let backend = detect_backend(target_os).await?;
625 guard.insert(target_os, Arc::clone(&backend));
626 Ok(backend)
627}
628
629#[allow(clippy::too_many_arguments)]
635async fn build_one_image(
636 name: &str,
637 pipeline: &ZPipeline,
638 base_dir: &Path,
639 executor: BuildahExecutor,
640 explicit_backend: Option<Arc<dyn BuildBackend>>,
641 backend_cache: &Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>>,
642 registry_root: Option<&Path>,
643 host_network: bool,
644) -> Result<BuiltImage> {
645 let image_config = &pipeline.images[name];
646 let platforms = effective_platforms(image_config, &pipeline.defaults);
647
648 let target_os = target_os_for_image(&platforms, image_config.os)?;
650 info!(
651 "Building image '{}' (target_os={:?}, platforms={:?}, explicit_os={:?})",
652 name, target_os, platforms, image_config.os
653 );
654
655 let backend = backend_for(target_os, backend_cache, explicit_backend).await?;
656
657 match platforms.len() {
658 0 => {
660 build_single_image(
661 name,
662 pipeline,
663 base_dir,
664 executor,
665 Some(backend),
666 None,
667 registry_root,
668 host_network,
669 )
670 .await
671 }
672 1 => {
674 let platform = platforms[0].clone();
675 build_single_image(
676 name,
677 pipeline,
678 base_dir,
679 executor,
680 Some(backend),
681 Some(&platform),
682 registry_root,
683 host_network,
684 )
685 .await
686 }
687 _ => {
689 build_multiplatform_image(
690 name,
691 pipeline,
692 base_dir,
693 executor,
694 Some(backend),
695 &platforms,
696 registry_root,
697 host_network,
698 )
699 .await
700 }
701 }
702}
703
704fn platform_to_suffix(platform: &str) -> String {
713 let parts: Vec<&str> = platform.split('/').collect();
714 match parts.len() {
715 0 | 1 => platform.replace('/', "-"),
716 2 => parts[1].to_string(),
717 _ => format!("{}-{}", parts[1], parts[2]),
718 }
719}
720
721fn apply_pipeline_config(
727 mut builder: ImageBuilder,
728 image_config: &PipelineImage,
729 defaults: &PipelineDefaults,
730) -> ImageBuilder {
731 let mut args = defaults.build_args.clone();
733 args.extend(image_config.build_args.clone());
734 builder = builder.build_args(args);
735
736 if let Some(fmt) = image_config.format.as_ref().or(defaults.format.as_ref()) {
738 builder = builder.format(fmt);
739 }
740
741 if image_config.no_cache.unwrap_or(defaults.no_cache) {
743 builder = builder.no_cache();
744 }
745
746 let mut cache_mounts = defaults.cache_mounts.clone();
748 cache_mounts.extend(image_config.cache_mounts.clone());
749 if !cache_mounts.is_empty() {
750 let run_mounts: Vec<_> = cache_mounts
751 .iter()
752 .map(crate::zimage::convert_cache_mount)
753 .collect();
754 builder = builder.default_cache_mounts(run_mounts);
755 }
756
757 let retries = image_config.retries.or(defaults.retries).unwrap_or(0);
759 if retries > 0 {
760 builder = builder.retries(retries);
761 }
762
763 builder
764}
765
766fn apply_build_file(builder: ImageBuilder, file_path: &Path) -> ImageBuilder {
769 let file_name = file_path
770 .file_name()
771 .map(|n| n.to_string_lossy().to_string())
772 .unwrap_or_default();
773 let extension = file_path
774 .extension()
775 .map(|e| e.to_string_lossy().to_string())
776 .unwrap_or_default();
777
778 if extension == "yaml" || extension == "yml" || file_name.starts_with("ZImagefile") {
779 builder.zimagefile(file_path)
780 } else {
781 builder.dockerfile(file_path)
782 }
783}
784
785async fn compute_file_hash(path: &Path) -> Option<String> {
789 use sha2::{Digest, Sha256};
790
791 let content = tokio::fs::read(path).await.ok()?;
792 let mut hasher = Sha256::new();
793 hasher.update(&content);
794 Some(format!("{:x}", hasher.finalize()))
795}
796
797fn sanitize_image_name_for_cache(image: &str) -> String {
801 image.replace(['/', ':', '@'], "_")
802}
803
804async fn check_cached_image_hash(
809 data_dir: &Path,
810 tag: &str,
811 expected_hash: &str,
812) -> Option<String> {
813 let sanitized = sanitize_image_name_for_cache(tag);
814 let config_path = data_dir.join("images").join(&sanitized).join("config.json");
815 let data = tokio::fs::read_to_string(&config_path).await.ok()?;
816 let config: CachedImageConfig = serde_json::from_str(&data).ok()?;
817 if config.source_hash.as_deref() == Some(expected_hash) {
818 Some(sanitized)
819 } else {
820 None
821 }
822}
823
824#[cfg_attr(not(feature = "local-registry"), allow(unused_variables))]
832#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
833async fn build_single_image(
834 name: &str,
835 pipeline: &ZPipeline,
836 base_dir: &Path,
837 executor: BuildahExecutor,
838 backend: Option<Arc<dyn BuildBackend>>,
839 platform: Option<&str>,
840 registry_root: Option<&Path>,
841 host_network: bool,
842) -> Result<BuiltImage> {
843 let image_config = &pipeline.images[name];
844 let context = base_dir.join(&image_config.context);
845 let file_path = base_dir.join(&image_config.file);
846
847 let file_hash = compute_file_hash(&file_path).await;
850 if let Some(ref hash) = file_hash {
851 let data_dir = ZLayerDirs::default_data_dir();
852
853 let expanded_tags: Vec<String> = image_config
854 .tags
855 .iter()
856 .map(|t| expand_tag_with_vars(t, &pipeline.vars))
857 .collect();
858
859 if let Some(first_tag) = expanded_tags.first() {
861 if let Some(cached_id) = check_cached_image_hash(&data_dir, first_tag, hash).await {
862 info!(
863 "[{}] Skipping build — cached image hash matches ({})",
864 name, cached_id
865 );
866 return Ok(BuiltImage {
867 image_id: cached_id,
868 tags: expanded_tags,
869 layer_count: 1,
870 size: 0,
871 build_time_ms: 0,
872 is_manifest: false,
873 });
874 }
875 }
876 }
877
878 let effective_backend: Arc<dyn BuildBackend> = backend
879 .unwrap_or_else(|| Arc::new(crate::backend::BuildahBackend::with_executor(executor)));
880 let mut builder = ImageBuilder::with_backend(&context, effective_backend)?;
881
882 builder = apply_build_file(builder, &file_path);
884
885 builder = builder.pipeline_vars(pipeline.vars.clone());
889
890 if let Some(ltsc) = pipeline.vars.get("LTSC") {
895 builder = builder.windows_ltsc(ltsc.clone());
896 }
897
898 if let Some(hash) = file_hash {
900 builder = builder.source_hash(hash);
901 }
902
903 if let Some(plat) = platform {
905 builder = builder.platform(plat);
906 }
907
908 for tag in &image_config.tags {
910 let expanded = expand_tag_with_vars(tag, &pipeline.vars);
911 builder = builder.tag(expanded);
912 }
913
914 builder = apply_pipeline_config(builder, image_config, &pipeline.defaults);
916
917 builder = builder.with_host_network(host_network);
920
921 #[cfg(feature = "local-registry")]
923 if let Some(root) = registry_root {
924 let shared_registry = LocalRegistry::new(root.to_path_buf()).await.map_err(|e| {
925 BuildError::invalid_instruction(
926 "pipeline",
927 format!("failed to open local registry: {e}"),
928 )
929 })?;
930 builder = builder.with_local_registry(shared_registry);
931 }
932
933 builder.build().await
934}
935
936#[cfg_attr(not(feature = "local-registry"), allow(unused_variables))]
942#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
943async fn build_multiplatform_image(
944 name: &str,
945 pipeline: &ZPipeline,
946 base_dir: &Path,
947 executor: BuildahExecutor,
948 backend: Option<Arc<dyn BuildBackend>>,
949 platforms: &[String],
950 registry_root: Option<&Path>,
951 host_network: bool,
952) -> Result<BuiltImage> {
953 let image_config = &pipeline.images[name];
954 let start_time = std::time::Instant::now();
955
956 let expanded_tags: Vec<String> = image_config
958 .tags
959 .iter()
960 .map(|t| expand_tag_with_vars(t, &pipeline.vars))
961 .collect();
962
963 let manifest_name = expanded_tags
964 .first()
965 .cloned()
966 .unwrap_or_else(|| format!("zlayer-manifest-{name}"));
967
968 let mut arch_tags: Vec<String> = Vec::new();
970 let mut total_layers = 0usize;
971 let mut total_size = 0u64;
972
973 for platform in platforms {
974 let suffix = platform_to_suffix(platform);
975 let platform_tags: Vec<String> = expanded_tags
976 .iter()
977 .map(|t| format!("{t}-{suffix}"))
978 .collect();
979
980 info!("[{name}] Building for platform {platform}");
981
982 let context = base_dir.join(&image_config.context);
984 let file_path = base_dir.join(&image_config.file);
985
986 let effective_backend: Arc<dyn BuildBackend> = match backend {
987 Some(ref b) => Arc::clone(b),
988 None => Arc::new(crate::backend::BuildahBackend::with_executor(
989 executor.clone(),
990 )),
991 };
992 let mut builder = ImageBuilder::with_backend(&context, effective_backend)?;
993
994 builder = apply_build_file(builder, &file_path);
996
997 builder = builder.pipeline_vars(pipeline.vars.clone());
999
1000 if let Some(ltsc) = pipeline.vars.get("LTSC") {
1004 builder = builder.windows_ltsc(ltsc.clone());
1005 }
1006
1007 builder = builder.platform(platform);
1009
1010 for tag in &platform_tags {
1012 builder = builder.tag(tag);
1013 }
1014
1015 builder = apply_pipeline_config(builder, image_config, &pipeline.defaults);
1017
1018 builder = builder.with_host_network(host_network);
1021
1022 #[cfg(feature = "local-registry")]
1024 if let Some(root) = registry_root {
1025 let shared_registry = LocalRegistry::new(root.to_path_buf()).await.map_err(|e| {
1026 BuildError::invalid_instruction(
1027 "pipeline",
1028 format!("failed to open local registry: {e}"),
1029 )
1030 })?;
1031 builder = builder.with_local_registry(shared_registry);
1032 }
1033
1034 let built = builder.build().await?;
1035 total_layers += built.layer_count;
1036 total_size += built.size;
1037
1038 if let Some(first_tag) = platform_tags.first() {
1039 arch_tags.push(first_tag.clone());
1040 }
1041 }
1042
1043 assemble_manifest(
1045 name,
1046 &manifest_name,
1047 &arch_tags,
1048 &expanded_tags,
1049 backend.as_ref(),
1050 &executor,
1051 )
1052 .await?;
1053
1054 #[allow(clippy::cast_possible_truncation)]
1055 let build_time_ms = start_time.elapsed().as_millis() as u64;
1056
1057 Ok(BuiltImage {
1058 image_id: manifest_name,
1059 tags: expanded_tags,
1060 layer_count: total_layers,
1061 size: total_size,
1062 build_time_ms,
1063 is_manifest: true,
1064 })
1065}
1066
1067async fn assemble_manifest(
1072 name: &str,
1073 manifest_name: &str,
1074 arch_tags: &[String],
1075 expanded_tags: &[String],
1076 backend: Option<&Arc<dyn BuildBackend>>,
1077 executor: &BuildahExecutor,
1078) -> Result<()> {
1079 info!("[{name}] Creating manifest: {manifest_name}");
1081 if let Some(backend) = backend {
1082 backend
1083 .manifest_create(manifest_name)
1084 .await
1085 .map_err(|e| BuildError::pipeline_error(format!("manifest create failed: {e}")))?;
1086 } else {
1087 executor
1090 .manifest_create_idempotent(manifest_name)
1091 .await
1092 .map_err(|e| BuildError::pipeline_error(format!("manifest create failed: {e}")))?;
1093 }
1094
1095 for arch_tag in arch_tags {
1097 info!("[{name}] Adding to manifest: {arch_tag}");
1098 if let Some(backend) = backend {
1099 backend
1100 .manifest_add(manifest_name, arch_tag)
1101 .await
1102 .map_err(|e| BuildError::pipeline_error(format!("manifest add failed: {e}")))?;
1103 } else {
1104 executor
1105 .execute_checked(&BuildahCommand::manifest_add(manifest_name, arch_tag))
1106 .await
1107 .map_err(|e| BuildError::pipeline_error(format!("manifest add failed: {e}")))?;
1108 }
1109 }
1110
1111 for tag in expanded_tags.iter().skip(1) {
1113 if let Some(backend) = backend {
1114 backend
1115 .tag_image(manifest_name, tag)
1116 .await
1117 .map_err(|e| BuildError::pipeline_error(format!("manifest tag failed: {e}")))?;
1118 } else {
1119 executor
1120 .execute_checked(&BuildahCommand::tag(manifest_name, tag))
1121 .await
1122 .map_err(|e| BuildError::pipeline_error(format!("manifest tag failed: {e}")))?;
1123 }
1124 }
1125
1126 Ok(())
1127}
1128
1129fn expand_tag_with_vars(tag: &str, vars: &HashMap<String, String>) -> String {
1133 let mut result = tag.to_string();
1134 for (key, value) in vars {
1135 result = result.replace(&format!("${{{key}}}"), value);
1136 }
1137 result
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143 use crate::pipeline::parse_pipeline;
1144
1145 #[test]
1146 fn test_resolve_execution_order_simple() {
1147 let yaml = r"
1148images:
1149 app:
1150 file: Dockerfile
1151";
1152 let pipeline = parse_pipeline(yaml).unwrap();
1153 let executor = PipelineExecutor::new(
1154 pipeline,
1155 PathBuf::from("/tmp"),
1156 BuildahExecutor::with_path("/usr/bin/buildah"),
1157 );
1158
1159 let waves = executor.resolve_execution_order().unwrap();
1160 assert_eq!(waves.len(), 1);
1161 assert_eq!(waves[0], vec!["app"]);
1162 }
1163
1164 #[test]
1165 fn test_resolve_execution_order_with_deps() {
1166 let yaml = r"
1167images:
1168 base:
1169 file: Dockerfile.base
1170 app:
1171 file: Dockerfile.app
1172 depends_on: [base]
1173 test:
1174 file: Dockerfile.test
1175 depends_on: [app]
1176";
1177 let pipeline = parse_pipeline(yaml).unwrap();
1178 let executor = PipelineExecutor::new(
1179 pipeline,
1180 PathBuf::from("/tmp"),
1181 BuildahExecutor::with_path("/usr/bin/buildah"),
1182 );
1183
1184 let waves = executor.resolve_execution_order().unwrap();
1185 assert_eq!(waves.len(), 3);
1186 assert_eq!(waves[0], vec!["base"]);
1187 assert_eq!(waves[1], vec!["app"]);
1188 assert_eq!(waves[2], vec!["test"]);
1189 }
1190
1191 #[test]
1192 fn test_resolve_execution_order_parallel() {
1193 let yaml = r"
1194images:
1195 base:
1196 file: Dockerfile.base
1197 app1:
1198 file: Dockerfile.app1
1199 depends_on: [base]
1200 app2:
1201 file: Dockerfile.app2
1202 depends_on: [base]
1203";
1204 let pipeline = parse_pipeline(yaml).unwrap();
1205 let executor = PipelineExecutor::new(
1206 pipeline,
1207 PathBuf::from("/tmp"),
1208 BuildahExecutor::with_path("/usr/bin/buildah"),
1209 );
1210
1211 let waves = executor.resolve_execution_order().unwrap();
1212 assert_eq!(waves.len(), 2);
1213 assert_eq!(waves[0], vec!["base"]);
1214 assert_eq!(waves[1].len(), 2);
1216 assert!(waves[1].contains(&"app1".to_string()));
1217 assert!(waves[1].contains(&"app2".to_string()));
1218 }
1219
1220 #[test]
1221 fn test_resolve_execution_order_missing_dep() {
1222 let yaml = r"
1223images:
1224 app:
1225 file: Dockerfile
1226 depends_on: [missing]
1227";
1228 let pipeline = parse_pipeline(yaml).unwrap();
1229 let executor = PipelineExecutor::new(
1230 pipeline,
1231 PathBuf::from("/tmp"),
1232 BuildahExecutor::with_path("/usr/bin/buildah"),
1233 );
1234
1235 let result = executor.resolve_execution_order();
1236 assert!(result.is_err());
1237 assert!(result.unwrap_err().to_string().contains("missing"));
1238 }
1239
1240 #[test]
1241 fn test_resolve_execution_order_circular() {
1242 let yaml = r"
1243images:
1244 a:
1245 file: Dockerfile.a
1246 depends_on: [b]
1247 b:
1248 file: Dockerfile.b
1249 depends_on: [a]
1250";
1251 let pipeline = parse_pipeline(yaml).unwrap();
1252 let executor = PipelineExecutor::new(
1253 pipeline,
1254 PathBuf::from("/tmp"),
1255 BuildahExecutor::with_path("/usr/bin/buildah"),
1256 );
1257
1258 let result = executor.resolve_execution_order();
1259 assert!(result.is_err());
1260 match result.unwrap_err() {
1261 BuildError::CircularDependency { stages } => {
1262 assert!(stages.contains(&"a".to_string()));
1263 assert!(stages.contains(&"b".to_string()));
1264 }
1265 e => panic!("Expected CircularDependency error, got: {e:?}"),
1266 }
1267 }
1268
1269 #[test]
1270 fn test_expand_tag() {
1271 let mut vars = HashMap::new();
1272 vars.insert("VERSION".to_string(), "1.0.0".to_string());
1273 vars.insert("REGISTRY".to_string(), "ghcr.io/myorg".to_string());
1274
1275 let tag = "${REGISTRY}/app:${VERSION}";
1276 let expanded = expand_tag_with_vars(tag, &vars);
1277 assert_eq!(expanded, "ghcr.io/myorg/app:1.0.0");
1278 }
1279
1280 #[test]
1281 fn test_expand_tag_partial() {
1282 let mut vars = HashMap::new();
1283 vars.insert("VERSION".to_string(), "1.0.0".to_string());
1284
1285 let tag = "myapp:${VERSION}-${UNKNOWN}";
1287 let expanded = expand_tag_with_vars(tag, &vars);
1288 assert_eq!(expanded, "myapp:1.0.0-${UNKNOWN}");
1289 }
1290
1291 #[test]
1292 fn test_pipeline_result_is_success() {
1293 let mut result = PipelineResult {
1294 succeeded: HashMap::new(),
1295 failed: HashMap::new(),
1296 total_time_ms: 100,
1297 };
1298
1299 assert!(result.is_success());
1300
1301 result.failed.insert("app".to_string(), "error".to_string());
1302 assert!(!result.is_success());
1303 }
1304
1305 #[test]
1306 fn test_pipeline_result_total_images() {
1307 let mut result = PipelineResult {
1308 succeeded: HashMap::new(),
1309 failed: HashMap::new(),
1310 total_time_ms: 100,
1311 };
1312
1313 result.succeeded.insert(
1314 "app1".to_string(),
1315 BuiltImage {
1316 image_id: "sha256:abc".to_string(),
1317 tags: vec!["app1:latest".to_string()],
1318 layer_count: 5,
1319 size: 0,
1320 build_time_ms: 50,
1321 is_manifest: false,
1322 },
1323 );
1324 result
1325 .failed
1326 .insert("app2".to_string(), "error".to_string());
1327
1328 assert_eq!(result.total_images(), 2);
1329 }
1330
1331 #[test]
1332 fn test_builder_methods() {
1333 let yaml = r"
1334images:
1335 app:
1336 file: Dockerfile
1337push:
1338 after_all: true
1339";
1340 let pipeline = parse_pipeline(yaml).unwrap();
1341 let executor = PipelineExecutor::new(
1342 pipeline,
1343 PathBuf::from("/tmp"),
1344 BuildahExecutor::with_path("/usr/bin/buildah"),
1345 )
1346 .fail_fast(false)
1347 .push(false);
1348
1349 assert!(!executor.fail_fast);
1350 assert!(!executor.push_enabled);
1351 }
1352
1353 fn test_pipeline_image() -> PipelineImage {
1355 PipelineImage {
1356 file: PathBuf::from("Dockerfile"),
1357 context: PathBuf::from("."),
1358 tags: vec![],
1359 build_args: HashMap::new(),
1360 depends_on: vec![],
1361 no_cache: None,
1362 format: None,
1363 cache_mounts: vec![],
1364 retries: None,
1365 platforms: vec![],
1366 os: None,
1367 }
1368 }
1369
1370 #[test]
1371 fn test_platform_to_suffix() {
1372 assert_eq!(platform_to_suffix("linux/amd64"), "amd64");
1373 assert_eq!(platform_to_suffix("linux/arm64"), "arm64");
1374 assert_eq!(platform_to_suffix("linux/arm64/v8"), "arm64-v8");
1375 assert_eq!(platform_to_suffix("linux"), "linux");
1376 }
1377
1378 #[test]
1379 fn test_effective_platforms_image_overrides() {
1380 let defaults = PipelineDefaults {
1381 platforms: vec!["linux/amd64".into()],
1382 ..Default::default()
1383 };
1384 let image = PipelineImage {
1385 platforms: vec!["linux/arm64".into()],
1386 ..test_pipeline_image()
1387 };
1388 assert_eq!(effective_platforms(&image, &defaults), vec!["linux/arm64"]);
1389 }
1390
1391 #[test]
1392 fn test_effective_platforms_inherits_defaults() {
1393 let defaults = PipelineDefaults {
1394 platforms: vec!["linux/amd64".into()],
1395 ..Default::default()
1396 };
1397 let image = test_pipeline_image();
1398 assert_eq!(effective_platforms(&image, &defaults), vec!["linux/amd64"]);
1399 }
1400
1401 #[test]
1402 fn test_effective_platforms_empty() {
1403 let defaults = PipelineDefaults::default();
1404 let image = test_pipeline_image();
1405 assert!(effective_platforms(&image, &defaults).is_empty());
1406 }
1407
1408 #[test]
1409 fn test_platform_to_suffix_edge_cases() {
1410 assert_eq!(platform_to_suffix(""), "");
1412 assert_eq!(platform_to_suffix("linux"), "linux");
1414 assert_eq!(platform_to_suffix("linux/arm/v7/extra"), "arm-v7");
1416 }
1417
1418 #[test]
1419 fn test_effective_platforms_multiple_defaults() {
1420 let defaults = PipelineDefaults {
1421 platforms: vec!["linux/amd64".into(), "linux/arm64".into()],
1422 ..Default::default()
1423 };
1424 let image = test_pipeline_image();
1425 assert_eq!(
1426 effective_platforms(&image, &defaults),
1427 vec!["linux/amd64", "linux/arm64"]
1428 );
1429 }
1430
1431 #[test]
1432 fn test_effective_platforms_image_overrides_multiple() {
1433 let defaults = PipelineDefaults {
1434 platforms: vec!["linux/amd64".into(), "linux/arm64".into()],
1435 ..Default::default()
1436 };
1437 let image = PipelineImage {
1438 platforms: vec!["linux/s390x".into()],
1439 ..test_pipeline_image()
1440 };
1441 assert_eq!(effective_platforms(&image, &defaults), vec!["linux/s390x"]);
1443 }
1444
1445 #[test]
1450 fn test_target_os_for_image_empty_defaults_to_linux() {
1451 assert_eq!(target_os_for_image(&[], None).unwrap(), ImageOs::Linux);
1452 }
1453
1454 #[test]
1455 fn test_target_os_for_image_single_linux() {
1456 assert_eq!(
1457 target_os_for_image(&["linux/amd64".to_string()], None).unwrap(),
1458 ImageOs::Linux
1459 );
1460 }
1461
1462 #[test]
1463 fn test_target_os_for_image_single_windows() {
1464 assert_eq!(
1465 target_os_for_image(&["windows/amd64".to_string()], None).unwrap(),
1466 ImageOs::Windows
1467 );
1468 }
1469
1470 #[test]
1471 fn test_target_os_for_image_multi_same_os() {
1472 let plats = vec!["linux/amd64".to_string(), "linux/arm64".to_string()];
1474 assert_eq!(target_os_for_image(&plats, None).unwrap(), ImageOs::Linux);
1475 }
1476
1477 #[test]
1478 fn test_target_os_for_image_mixed_os_is_rejected() {
1479 let plats = vec!["linux/amd64".to_string(), "windows/amd64".to_string()];
1482 let err = target_os_for_image(&plats, None).unwrap_err();
1483 let msg = err.to_string();
1484 assert!(
1485 msg.contains("cannot mix OSes"),
1486 "expected mix-of-OSes error, got: {msg}"
1487 );
1488 assert!(
1489 msg.contains("split into separate PipelineImage entries"),
1490 "expected remediation hint, got: {msg}"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_target_os_for_image_unrecognized_platform() {
1496 let plats = vec!["plan9/amd64".to_string()];
1497 let err = target_os_for_image(&plats, None).unwrap_err();
1498 assert!(err.to_string().contains("unrecognized platform"));
1499 }
1500
1501 #[test]
1502 fn test_target_os_for_image_explicit_os_wins_empty_platforms() {
1503 assert_eq!(
1506 target_os_for_image(&[], Some(ImageOs::Windows)).unwrap(),
1507 ImageOs::Windows
1508 );
1509 }
1510
1511 #[test]
1512 fn test_target_os_for_image_explicit_os_matches_platforms() {
1513 let plats = vec!["windows/amd64".to_string()];
1515 assert_eq!(
1516 target_os_for_image(&plats, Some(ImageOs::Windows)).unwrap(),
1517 ImageOs::Windows
1518 );
1519 }
1520
1521 #[test]
1522 fn test_target_os_for_image_explicit_os_conflicts_with_platforms() {
1523 let plats = vec!["linux/amd64".to_string()];
1526 let err = target_os_for_image(&plats, Some(ImageOs::Windows)).unwrap_err();
1527 let msg = err.to_string();
1528 assert!(
1529 msg.contains("explicit os=")
1530 && msg.contains("conflicts with OS inferred from platforms"),
1531 "expected conflict error, got: {msg}"
1532 );
1533 }
1534
1535 #[test]
1536 fn test_target_os_for_image_explicit_os_wins_darwin_empty_platforms() {
1537 assert_eq!(
1540 target_os_for_image(&[], Some(ImageOs::Darwin)).unwrap(),
1541 ImageOs::Darwin
1542 );
1543 }
1544
1545 #[test]
1546 fn test_target_os_for_image_single_darwin_from_platforms() {
1547 assert_eq!(
1549 target_os_for_image(&["darwin/arm64".to_string()], None).unwrap(),
1550 ImageOs::Darwin
1551 );
1552 }
1553
1554 #[test]
1555 fn test_target_os_for_image_explicit_darwin_matches_platforms() {
1556 let plats = vec!["darwin/arm64".to_string()];
1558 assert_eq!(
1559 target_os_for_image(&plats, Some(ImageOs::Darwin)).unwrap(),
1560 ImageOs::Darwin
1561 );
1562 }
1563
1564 struct FakeBackend {
1568 name: &'static str,
1569 }
1570
1571 #[async_trait::async_trait]
1572 impl BuildBackend for FakeBackend {
1573 async fn build_image(
1574 &self,
1575 _context: &Path,
1576 _dockerfile: &crate::dockerfile::Dockerfile,
1577 options: &crate::builder::BuildOptions,
1578 _event_tx: Option<std::sync::mpsc::Sender<crate::tui::BuildEvent>>,
1579 ) -> Result<BuiltImage> {
1580 Ok(BuiltImage {
1581 image_id: format!("{}:fake-id", self.name),
1582 tags: options.tags.clone(),
1583 layer_count: 1,
1584 size: 0,
1585 build_time_ms: 1,
1586 is_manifest: false,
1587 })
1588 }
1589
1590 async fn push_image(
1591 &self,
1592 _tag: &str,
1593 _auth: Option<&crate::builder::RegistryAuth>,
1594 ) -> Result<()> {
1595 Ok(())
1596 }
1597
1598 async fn tag_image(&self, _image: &str, _new_tag: &str) -> Result<()> {
1599 Ok(())
1600 }
1601
1602 async fn manifest_create(&self, _name: &str) -> Result<()> {
1603 Ok(())
1604 }
1605
1606 async fn manifest_add(&self, _manifest: &str, _image: &str) -> Result<()> {
1607 Ok(())
1608 }
1609
1610 async fn manifest_push(
1611 &self,
1612 _name: &str,
1613 _destination: &str,
1614 _auth: Option<&crate::builder::RegistryAuth>,
1615 ) -> Result<()> {
1616 Ok(())
1617 }
1618
1619 async fn is_available(&self) -> bool {
1620 true
1621 }
1622
1623 fn name(&self) -> &'static str {
1624 self.name
1625 }
1626 }
1627
1628 #[tokio::test]
1629 async fn test_backend_for_uses_explicit_override() {
1630 let explicit: Arc<dyn BuildBackend> = Arc::new(FakeBackend { name: "explicit" });
1633 let cache: Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>> = Mutex::new(HashMap::new());
1634 let resolved = backend_for(ImageOs::Linux, &cache, Some(Arc::clone(&explicit)))
1635 .await
1636 .unwrap();
1637 assert_eq!(resolved.name(), "explicit");
1638 assert!(
1639 cache.lock().await.is_empty(),
1640 "explicit override should not populate cache"
1641 );
1642 }
1643
1644 #[tokio::test]
1645 async fn test_backend_for_cache_hit_returns_cached() {
1646 let fake: Arc<dyn BuildBackend> = Arc::new(FakeBackend { name: "cached" });
1649 let cache: Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>> = Mutex::new(HashMap::new());
1650 cache.lock().await.insert(ImageOs::Linux, Arc::clone(&fake));
1651 let resolved = backend_for(ImageOs::Linux, &cache, None).await.unwrap();
1652 assert_eq!(resolved.name(), "cached");
1653 }
1654
1655 #[cfg(not(target_os = "windows"))]
1661 #[tokio::test]
1662 async fn test_build_one_image_isolates_windows_failure_on_linux_host() {
1663 use tempfile::TempDir;
1664
1665 let tmp = TempDir::new().unwrap();
1666 let ctx = tmp.path();
1667 tokio::fs::write(ctx.join("Dockerfile"), "FROM scratch\n")
1670 .await
1671 .unwrap();
1672
1673 let yaml = r#"
1674images:
1675 linux-app:
1676 file: Dockerfile
1677 platforms: ["linux/amd64"]
1678 tags: ["example/linux:dev"]
1679 win-app:
1680 file: Dockerfile
1681 platforms: ["windows/amd64"]
1682 tags: ["example/windows:dev"]
1683"#;
1684 let pipeline = parse_pipeline(yaml).unwrap();
1685
1686 let cache: Arc<Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>>> =
1689 Arc::new(Mutex::new(HashMap::new()));
1690 let fake_linux: Arc<dyn BuildBackend> = Arc::new(FakeBackend { name: "fake-linux" });
1691 cache
1692 .lock()
1693 .await
1694 .insert(ImageOs::Linux, Arc::clone(&fake_linux));
1695
1696 let linux_res = build_one_image(
1698 "linux-app",
1699 &pipeline,
1700 ctx,
1701 BuildahExecutor::with_path("/usr/bin/buildah"),
1702 None, &cache,
1704 None,
1705 false,
1706 )
1707 .await;
1708 assert!(
1709 linux_res.is_ok(),
1710 "Linux image should succeed, got: {linux_res:?}"
1711 );
1712 assert_eq!(linux_res.unwrap().image_id, "fake-linux:fake-id");
1713
1714 let win_res = build_one_image(
1717 "win-app",
1718 &pipeline,
1719 ctx,
1720 BuildahExecutor::with_path("/usr/bin/buildah"),
1721 None,
1722 &cache,
1723 None,
1724 false,
1725 )
1726 .await;
1727 let err = win_res.unwrap_err();
1728 let msg = err.to_string();
1729 assert!(
1730 msg.contains("Windows host") || msg.contains("windows host"),
1731 "expected Windows-host error from detect_backend, got: {msg}"
1732 );
1733
1734 let guard = cache.lock().await;
1737 assert!(guard.contains_key(&ImageOs::Linux));
1738 assert!(!guard.contains_key(&ImageOs::Windows));
1739 }
1740}