Skip to main content

zlayer_builder/pipeline/
executor.rs

1//! Pipeline executor - Coordinates building multiple images with wave-based orchestration
2//!
3//! This module provides the [`PipelineExecutor`] which processes [`ZPipeline`] manifests,
4//! resolving dependencies and building images in parallel waves.
5//!
6//! # Execution Model
7//!
8//! Images are grouped into "waves" based on their dependency depth:
9//! - **Wave 0**: Images with no dependencies - can all run in parallel
10//! - **Wave 1**: Images that depend only on Wave 0 images
11//! - **Wave N**: Images that depend only on images from earlier waves
12//!
13//! Within each wave, all builds run concurrently, sharing the same
14//! [`BuildahExecutor`] (and thus cache storage).
15//!
16//! # Example
17//!
18//! ```no_run
19//! use zlayer_builder::pipeline::{PipelineExecutor, parse_pipeline};
20//! use zlayer_builder::BuildahExecutor;
21//! use std::path::PathBuf;
22//!
23//! # async fn example() -> Result<(), zlayer_builder::BuildError> {
24//! let yaml = std::fs::read_to_string("ZPipeline.yaml")?;
25//! let pipeline = parse_pipeline(&yaml)?;
26//!
27//! let executor = BuildahExecutor::new_async().await?;
28//! let result = PipelineExecutor::new(pipeline, PathBuf::from("."), executor)
29//!     .fail_fast(true)
30//!     .run()
31//!     .await?;
32//!
33//! println!("Built {} images in {}ms", result.succeeded.len(), result.total_time_ms);
34//! # Ok(())
35//! # }
36//! ```
37
38use 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/// Minimal struct to read `source_hash` from a cached image's `config.json`.
52/// Separate from `SandboxImageConfig` which is macOS-only.
53#[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/// Result of a pipeline execution
69#[derive(Debug)]
70pub struct PipelineResult {
71    /// Images that were successfully built
72    pub succeeded: HashMap<String, BuiltImage>,
73    /// Images that failed to build (name -> error message)
74    pub failed: HashMap<String, String>,
75    /// Total execution time in milliseconds
76    pub total_time_ms: u64,
77}
78
79impl PipelineResult {
80    /// Returns true if all images were built successfully
81    #[must_use]
82    pub fn is_success(&self) -> bool {
83        self.failed.is_empty()
84    }
85
86    /// Returns the total number of images in the pipeline
87    #[must_use]
88    pub fn total_images(&self) -> usize {
89        self.succeeded.len() + self.failed.len()
90    }
91}
92
93/// Pipeline executor configuration and runtime
94///
95/// The executor processes a [`ZPipeline`] manifest, resolving dependencies
96/// and building images in parallel waves.
97pub struct PipelineExecutor {
98    /// The pipeline configuration
99    pipeline: ZPipeline,
100    /// Base directory for resolving relative paths
101    base_dir: PathBuf,
102    /// Buildah executor (shared across all builds)
103    executor: BuildahExecutor,
104    /// Pluggable build backend (buildah, sandbox, etc.).
105    ///
106    /// When set, builds delegate to this backend instead of using the
107    /// `executor` field directly. This is an *explicit* override: when
108    /// present, the executor uses the same backend for every image in the
109    /// pipeline regardless of the image's target OS.
110    backend: Option<Arc<dyn BuildBackend>>,
111    /// Per-target-OS backend cache used when `backend` is `None`.
112    ///
113    /// Each wave may contain images targeting different OSes (e.g. one Linux
114    /// image + one Windows image). Rather than re-run `detect_backend()` for
115    /// every image, we memoize the backend selected for each [`ImageOs`] the
116    /// first time we see it in the pipeline. Shared via `Arc<Mutex<_>>` so
117    /// spawned build tasks can resolve their backend concurrently.
118    backend_cache: Arc<Mutex<HashMap<ImageOs, Arc<dyn BuildBackend>>>>,
119    /// Whether to abort on first failure
120    fail_fast: bool,
121    /// Whether to push images after building
122    push_enabled: bool,
123    /// Whether to force `--net=host` on every `buildah run` for every image
124    /// in this pipeline. Forwarded to each [`ImageBuilder`] via
125    /// [`ImageBuilder::with_host_network`]. Mirrors the top-level
126    /// `zlayer --host-network` CLI flag.
127    host_network: bool,
128    /// Optional local registry for sharing built images between pipeline stages
129    #[cfg(feature = "local-registry")]
130    local_registry: Option<Arc<LocalRegistry>>,
131}
132
133impl PipelineExecutor {
134    /// Create a new pipeline executor
135    ///
136    /// # Arguments
137    ///
138    /// * `pipeline` - The parsed `ZPipeline` configuration
139    /// * `base_dir` - Base directory for resolving relative paths in the pipeline
140    /// * `executor` - The buildah executor to use for all builds
141    #[must_use]
142    pub fn new(pipeline: ZPipeline, base_dir: PathBuf, executor: BuildahExecutor) -> Self {
143        // Determine push behavior from pipeline config
144        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    /// Create a new pipeline executor with an explicit [`BuildBackend`].
161    ///
162    /// The backend is used for all build, push, and manifest operations.
163    /// A default `BuildahExecutor` is kept for backwards compatibility but
164    /// is not used when a backend is set.
165    ///
166    /// # Arguments
167    ///
168    /// * `pipeline` - The parsed `ZPipeline` configuration
169    /// * `base_dir` - Base directory for resolving relative paths in the pipeline
170    /// * `backend`  - The build backend to use for all operations
171    #[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    /// Set fail-fast mode (default: true)
194    ///
195    /// When enabled, the executor will abort immediately when any image
196    /// fails to build. When disabled, it will continue building independent
197    /// images even after failures.
198    #[must_use]
199    pub fn fail_fast(mut self, fail_fast: bool) -> Self {
200        self.fail_fast = fail_fast;
201        self
202    }
203
204    /// Enable or disable pushing (overrides `pipeline.push.after_all`)
205    ///
206    /// When enabled and all builds succeed, images will be pushed to their
207    /// configured registries.
208    #[must_use]
209    pub fn push(mut self, enabled: bool) -> Self {
210        self.push_enabled = enabled;
211        self
212    }
213
214    /// Force `--net=host` on every `buildah run` for every image in this
215    /// pipeline. Forwarded to each [`ImageBuilder`] via
216    /// [`ImageBuilder::with_host_network`]. Mirrors the top-level
217    /// `zlayer --host-network` CLI flag.
218    #[must_use]
219    pub fn with_host_network(mut self, on: bool) -> Self {
220        self.host_network = on;
221        self
222    }
223
224    /// Set a local registry for sharing built images between pipeline stages.
225    ///
226    /// When set, each image build receives a fresh [`LocalRegistry`] handle
227    /// pointing at the same on-disk root, so downstream images can resolve
228    /// base images that were built by earlier waves.
229    #[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    /// Resolve execution order into waves
237    ///
238    /// Returns a vector of waves, where each wave contains image names
239    /// that can be built in parallel. Images in wave N depend only on
240    /// images from waves 0..N-1.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if:
245    /// - An image depends on an unknown image
246    /// - A circular dependency is detected
247    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        // Validate: check for missing dependencies
253        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        // Build waves iteratively
265        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                // Can build if all dependencies are already assigned to previous waves
271                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                // No images could be added to this wave - circular dependency
279                return Err(BuildError::CircularDependency {
280                    stages: remaining.into_iter().collect(),
281                });
282            }
283
284            // Move wave images from remaining to assigned
285            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    /// Execute the pipeline
297    ///
298    /// Builds all images in dependency order, with images in the same wave
299    /// running in parallel.
300    ///
301    /// # Returns
302    ///
303    /// A [`PipelineResult`] containing information about successful and failed builds.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if:
308    /// - The dependency graph is invalid (missing deps, cycles)
309    /// - Any build fails and `fail_fast` is enabled
310    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            // Check if we should abort due to previous failures
327            if self.fail_fast && !failed.is_empty() {
328                warn!("Aborting pipeline due to previous failures (fail_fast enabled)");
329                break;
330            }
331
332            // Build all images in this wave concurrently
333            let wave_results = self.build_wave(wave).await;
334
335            // Process results
336            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 early with the first error
348                            return Err(e);
349                        }
350                    }
351                }
352            }
353        }
354
355        // Push phase (only if all succeeded and push enabled)
356        if self.push_enabled && failed.is_empty() {
357            info!("Pushing {} images", succeeded.len());
358
359            for (name, image) in &succeeded {
360                // Resolve the SAME backend that built this image (a per-OS
361                // cache hit from the build phase) so push/tag goes through it
362                // — e.g. the macOS Seatbelt sandbox's native push — instead of
363                // falling back to a `buildah` binary that may not exist.
364                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                // Ensure secondary tags have on-disk directories (the sandbox
373                // backend stores the rootfs under the first tag only; extra
374                // tags must be created before push can find them).
375                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                        // Push failures don't fail the overall pipeline
394                        // since the images were built successfully
395                    } 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    /// Build all images in a wave concurrently
413    ///
414    /// Each image is checked for multi-platform configuration. Images with
415    /// 2+ platforms use `build_multiplatform_image` (manifest list), images
416    /// with exactly 1 platform use `build_single_image` with that platform
417    /// set, and images with no platforms use the native platform (existing
418    /// behavior).
419    ///
420    /// # Per-image backend selection
421    ///
422    /// When the executor was built via [`PipelineExecutor::with_backend`]
423    /// (i.e. `self.backend.is_some()`), that explicit backend is used for
424    /// every image. Otherwise the target OS is parsed from each image's
425    /// `platforms` field and a backend is resolved via [`detect_backend`],
426    /// cached in `self.backend_cache` to avoid re-detection. An image that
427    /// cannot resolve a backend (e.g. Windows image on a Linux host) fails
428    /// only that image — other images in the wave continue.
429    ///
430    /// Returns a vector of (name, result) tuples for each image in the wave.
431    async fn build_wave(&self, wave: &[String]) -> Vec<(String, Result<BuiltImage>)> {
432        // Create shared data for spawned tasks
433        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        // Extract local registry root path (if configured) so spawned tasks
441        // can create their own LocalRegistry handles pointing at the same store.
442        #[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        // Collect all results
476        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                    // Task panicked
484                    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    /// Resolve the build backend for a pipeline image by name, reusing the
500    /// per-OS backend cache populated during the build phase (so push/tag use
501    /// the same backend — e.g. the Seatbelt sandbox — that built the image).
502    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    /// Push a regular image to its registry via the resolved backend.
510    async fn push_image(&self, backend: &Arc<dyn BuildBackend>, tag: &str) -> Result<()> {
511        backend.push_image(tag, None).await
512    }
513
514    /// Push a manifest list (and all referenced images) via the resolved backend.
515    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
521/// Get the effective platforms for an image, considering defaults.
522///
523/// If the image specifies its own platforms, those take precedence.
524/// Otherwise, the pipeline-level defaults are used. An empty result
525/// means "native platform only" (no multi-arch).
526fn 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
534/// Parse the target OS from an image's effective `platforms` list.
535///
536/// Rules:
537/// - `explicit_os` (e.g. `PipelineImage.os:`) takes precedence — when
538///   `Some(os)`, we also verify each `platforms` entry parses to the same
539///   OS so a misconfigured platform list surfaces loudly rather than being
540///   silently overridden.
541/// - Empty list with no explicit OS → [`ImageOs::Linux`] (the historical default).
542/// - All entries parse to the same OS → that OS.
543/// - Any entry fails to parse → an error naming the bad entry.
544/// - Entries parse to *different* OSes → an error, because buildah cannot
545///   assemble a single manifest list across OSes and the user should split
546///   the image into separate `PipelineImage` entries.
547fn target_os_for_image(platforms: &[String], explicit_os: Option<ImageOs>) -> Result<ImageOs> {
548    // Parse every entry so we still catch malformed strings even when an
549    // explicit OS is supplied; the L-7 validation semantics stay intact.
550    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
593/// Resolve a build backend for `target_os`, using `cache` to memoize prior
594/// detections.
595///
596/// If `explicit` is `Some`, it is returned unconditionally — callers that
597/// constructed the executor via [`PipelineExecutor::with_backend`] have opted
598/// into a single backend and we do not second-guess them. Otherwise we check
599/// the cache and fall back to [`detect_backend`] on a miss, storing the
600/// result before returning.
601async 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    // Fast path: already detected.
611    {
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    // Slow path: detect + memoize. Hold the lock across detection so two
619    // concurrent images targeting the same OS share one detection call.
620    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/// Build a single pipeline image end-to-end.
630///
631/// Parses the target OS, resolves a backend (with caching), logs the build
632/// intent, then dispatches to the single-platform or multi-platform path
633/// based on the effective platform list.
634#[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    // L-2: `PipelineImage.os:` takes precedence over OS parsed from platforms.
649    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        // No platforms specified — native build (existing behavior)
659        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        // Single platform — use build_single_image with platform set
673        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        // Multiple platforms — build each, then create manifest list
688        _ => {
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
704/// Extract architecture suffix from a platform string.
705///
706/// # Examples
707///
708/// - `"linux/amd64"` -> `"amd64"`
709/// - `"linux/arm64"` -> `"arm64"`
710/// - `"linux/arm64/v8"` -> `"arm64-v8"`
711/// - `"linux"` -> `"linux"`
712fn 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
721/// Apply pipeline configuration (`build_args`, format, `cache_mounts`, retries, `no_cache`)
722/// to an [`ImageBuilder`].
723///
724/// This merges default-level and per-image settings, with per-image values taking
725/// precedence for scalar settings and being additive for collections.
726fn apply_pipeline_config(
727    mut builder: ImageBuilder,
728    image_config: &PipelineImage,
729    defaults: &PipelineDefaults,
730) -> ImageBuilder {
731    // Merge build_args: defaults + per-image (per-image overrides defaults)
732    let mut args = defaults.build_args.clone();
733    args.extend(image_config.build_args.clone());
734    builder = builder.build_args(args);
735
736    // Format (per-image overrides default)
737    if let Some(fmt) = image_config.format.as_ref().or(defaults.format.as_ref()) {
738        builder = builder.format(fmt);
739    }
740
741    // No cache (per-image overrides default)
742    if image_config.no_cache.unwrap_or(defaults.no_cache) {
743        builder = builder.no_cache();
744    }
745
746    // Cache mounts: defaults + per-image (per-image are additive)
747    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    // Retries (per-image overrides default)
758    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
766/// Detect whether a build file is a ZImagefile/YAML or a Dockerfile and
767/// configure the builder accordingly.
768fn 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
785/// Compute a SHA-256 hash of a file's contents for content-based cache invalidation.
786///
787/// Returns `None` if the file cannot be read.
788async 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
797/// Sanitize an image reference into a filesystem-safe directory name.
798///
799/// Mirrors the logic in `sandbox_builder::sanitize_image_name`.
800fn sanitize_image_name_for_cache(image: &str) -> String {
801    image.replace(['/', ':', '@'], "_")
802}
803
804/// Check if a cached sandbox image at `data_dir/images/{sanitized}/config.json`
805/// has a `source_hash` matching `expected_hash`.
806///
807/// Returns the sanitized image name if a match is found.
808async 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/// Build a single image from the pipeline
825///
826/// This is extracted as a separate function to make it easier to spawn
827/// in a tokio task without borrowing issues.
828///
829/// When `platform` is `Some`, the builder is configured for that specific
830/// platform (e.g. `"linux/arm64"`), enabling cross-architecture builds.
831#[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    // Content-based cache invalidation: hash the build file and check if the
848    // output image was already built from identical source content.
849    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        // Check the first tag — if it has a cached image with matching hash, skip the build
860        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    // Determine if this is a ZImagefile or Dockerfile based on extension/name
883    builder = apply_build_file(builder, &file_path);
884
885    // Expand pipeline `${VAR}` references in the ZImagefile body (base:/run:),
886    // so a single ZImagefile set parametrizes across variants (e.g. the Windows
887    // `--set LTSC=...` lines). No-op for Dockerfiles and for empty vars.
888    builder = builder.pipeline_vars(pipeline.vars.clone());
889
890    // Forward the `LTSC` pipeline var to the Windows backend so FROM-image
891    // rewrites pick the correct prebuilt (`ltsc2022` vs `ltsc2025`). We only
892    // set it when the pipeline actually declares LTSC so non-Windows builds
893    // don't carry a meaningless field.
894    if let Some(ltsc) = pipeline.vars.get("LTSC") {
895        builder = builder.windows_ltsc(ltsc.clone());
896    }
897
898    // Pass the source hash so the sandbox builder stores it for future cache checks
899    if let Some(hash) = file_hash {
900        builder = builder.source_hash(hash);
901    }
902
903    // Set platform if specified
904    if let Some(plat) = platform {
905        builder = builder.platform(plat);
906    }
907
908    // Apply tags with variable expansion
909    for tag in &image_config.tags {
910        let expanded = expand_tag_with_vars(tag, &pipeline.vars);
911        builder = builder.tag(expanded);
912    }
913
914    // Apply shared pipeline config (build_args, format, no_cache, cache_mounts, retries)
915    builder = apply_pipeline_config(builder, image_config, &pipeline.defaults);
916
917    // Forward the pipeline-wide `--host-network` flag (from `zlayer
918    // --host-network pipeline ...`).
919    builder = builder.with_host_network(host_network);
920
921    // Wire up local registry so this build can resolve images from earlier waves
922    #[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/// Build an image for multiple platforms and create a manifest list.
937///
938/// Each platform is built sequentially (QEMU can be flaky with parallel
939/// cross-arch builds), then a buildah manifest list is created that
940/// references all per-platform images.
941#[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    // Expand tags with variables
957    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    // Build for each platform sequentially (QEMU can be flaky with parallel cross-arch)
969    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        // Build with platform-specific tags
983        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        // Determine file type (same detection as build_single_image)
995        builder = apply_build_file(builder, &file_path);
996
997        // Expand pipeline `${VAR}` refs in the ZImagefile body (see build_single_image).
998        builder = builder.pipeline_vars(pipeline.vars.clone());
999
1000        // Forward the `LTSC` pipeline var to the Windows backend (see
1001        // build_single_image for the rationale). Only set when actually
1002        // declared so non-Windows multi-platform builds aren't polluted.
1003        if let Some(ltsc) = pipeline.vars.get("LTSC") {
1004            builder = builder.windows_ltsc(ltsc.clone());
1005        }
1006
1007        // Set platform
1008        builder = builder.platform(platform);
1009
1010        // Apply platform-specific tags
1011        for tag in &platform_tags {
1012            builder = builder.tag(tag);
1013        }
1014
1015        // Apply shared config (build_args, format, no_cache, cache_mounts, retries)
1016        builder = apply_pipeline_config(builder, image_config, &pipeline.defaults);
1017
1018        // Forward the pipeline-wide `--host-network` flag (from `zlayer
1019        // --host-network pipeline ...`).
1020        builder = builder.with_host_network(host_network);
1021
1022        // Wire up local registry so this build can resolve images from earlier waves
1023        #[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 the manifest list from per-platform images
1044    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
1067/// Create a manifest list, add per-platform images, and apply additional tags.
1068///
1069/// This is a helper extracted from [`build_multiplatform_image`] to keep that
1070/// function under the line-count limit.
1071async 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    // Create manifest list — delegate to backend if available
1080    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        // Idempotent create: clears any stale list/image of this name first so
1088        // a re-run after a partial build doesn't hit "name already in use".
1089        executor
1090            .manifest_create_idempotent(manifest_name)
1091            .await
1092            .map_err(|e| BuildError::pipeline_error(format!("manifest create failed: {e}")))?;
1093    }
1094
1095    // Add each arch image to the manifest
1096    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    // Tag the manifest with additional tags
1112    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
1129/// Expand variables in a tag string
1130///
1131/// Standalone function for use in spawned tasks.
1132fn 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        // app1 and app2 should be in the same wave (order may vary)
1215        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        // Unknown vars are left as-is
1286        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    /// Helper to create a minimal `PipelineImage` for tests.
1354    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        // Empty string
1411        assert_eq!(platform_to_suffix(""), "");
1412        // Single component
1413        assert_eq!(platform_to_suffix("linux"), "linux");
1414        // Four components (unusual but handle gracefully)
1415        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        // Image platforms completely replace defaults, not merge
1442        assert_eq!(effective_platforms(&image, &defaults), vec!["linux/s390x"]);
1443    }
1444
1445    // -----------------------------------------------------------------------
1446    // L-7: per-image backend selection
1447    // -----------------------------------------------------------------------
1448
1449    #[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        // All-Linux entries should collapse to a single Linux backend.
1473        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        // Mixed OSes in a single entry must fail — there is no single backend
1480        // (or buildah manifest list) that can straddle Linux + Windows.
1481        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        // L-2: explicit os: on PipelineImage overrides the default Linux
1504        // inference when no platforms are set.
1505        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        // Explicit os: agrees with the OS portion of platforms — totally fine.
1514        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        // Explicit os: disagrees with platforms — must fail loudly so we don't
1524        // silently override user intent.
1525        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        // Explicit os: darwin on PipelineImage overrides the default Linux
1538        // inference when no platforms are set.
1539        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        // A darwin/* platform with no explicit os: infers Darwin.
1548        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        // Explicit os: darwin agrees with the OS portion of platforms — fine.
1557        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    /// A fake [`BuildBackend`] that records which tags it was asked to build
1565    /// and returns synthetic [`BuiltImage`] values. Used to exercise the
1566    /// per-image routing without invoking the real buildah CLI.
1567    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        // When `explicit` is supplied, `backend_for` must return that backend
1631        // regardless of target_os and must not touch the cache.
1632        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        // If the cache already has an entry for the target_os, `backend_for`
1647        // must return it verbatim without calling detect_backend.
1648        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    /// Mixed-OS wave: one Linux image (served by a seeded fake backend) and
1656    /// one Windows image. On a non-Windows host, `detect_backend(Windows)`
1657    /// returns an error from L-6; the Linux build should still succeed via
1658    /// the cached fake backend. This verifies per-image backend selection
1659    /// and per-image error isolation within a single wave.
1660    #[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        // A minimal Dockerfile is enough — the fake backend ignores its
1668        // contents, but the Dockerfile parser needs *something* to read.
1669        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        // Pre-seed the cache with a fake Linux backend so the Linux build
1687        // does not try to invoke real buildah.
1688        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        // Linux image should succeed via the seeded fake backend.
1697        let linux_res = build_one_image(
1698            "linux-app",
1699            &pipeline,
1700            ctx,
1701            BuildahExecutor::with_path("/usr/bin/buildah"),
1702            None, // no explicit override — exercise per-target_os routing
1703            &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        // Windows image should fail with the L-6 message — a Windows image
1715        // cannot be built on a non-Windows host.
1716        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        // The Windows detection failure must not pollute the cache for
1735        // ImageOs::Windows, and the Linux backend must remain cached.
1736        let guard = cache.lock().await;
1737        assert!(guard.contains_key(&ImageOs::Linux));
1738        assert!(!guard.contains_key(&ImageOs::Windows));
1739    }
1740}