Skip to main content

zlayer_builder/
builder.rs

1//! `ImageBuilder` - High-level API for building container images
2//!
3//! This module provides the [`ImageBuilder`] type which orchestrates the full
4//! container image build process, from Dockerfile parsing through buildah
5//! execution to final image creation.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use zlayer_builder::{ImageBuilder, Runtime};
11//!
12//! #[tokio::main]
13//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
14//!     // Build from a Dockerfile
15//!     let image = ImageBuilder::new("./my-app").await?
16//!         .tag("myapp:latest")
17//!         .tag("myapp:v1.0.0")
18//!         .build()
19//!         .await?;
20//!
21//!     println!("Built image: {}", image.image_id);
22//!     Ok(())
23//! }
24//! ```
25//!
26//! # Using Runtime Templates
27//!
28//! ```no_run
29//! use zlayer_builder::{ImageBuilder, Runtime};
30//!
31//! #[tokio::main]
32//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
33//!     // Build using a runtime template (no Dockerfile needed)
34//!     let image = ImageBuilder::new("./my-node-app").await?
35//!         .runtime(Runtime::Node20)
36//!         .tag("myapp:latest")
37//!         .build()
38//!         .await?;
39//!
40//!     println!("Built image: {}", image.image_id);
41//!     Ok(())
42//! }
43//! ```
44//!
45//! # Multi-stage Builds with Target
46//!
47//! ```no_run
48//! use zlayer_builder::ImageBuilder;
49//!
50//! #[tokio::main]
51//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
52//!     // Build only up to a specific stage
53//!     let image = ImageBuilder::new("./my-app").await?
54//!         .target("builder")
55//!         .tag("myapp:builder")
56//!         .build()
57//!         .await?;
58//!
59//!     println!("Built intermediate image: {}", image.image_id);
60//!     Ok(())
61//! }
62//! ```
63//!
64//! # With TUI Progress Updates
65//!
66//! ```no_run
67//! use zlayer_builder::{ImageBuilder, BuildEvent};
68//! use std::sync::mpsc;
69//!
70//! #[tokio::main]
71//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
72//!     let (tx, rx) = mpsc::channel::<BuildEvent>();
73//!
74//!     // Start TUI in another thread
75//!     std::thread::spawn(move || {
76//!         // Process events from rx...
77//!         while let Ok(event) = rx.recv() {
78//!             println!("Event: {:?}", event);
79//!         }
80//!     });
81//!
82//!     let image = ImageBuilder::new("./my-app").await?
83//!         .tag("myapp:latest")
84//!         .with_events(tx)
85//!         .build()
86//!         .await?;
87//!
88//!     Ok(())
89//! }
90//! ```
91//!
92//! # With Cache Backend (requires `cache` feature)
93//!
94//! ```no_run,ignore
95//! use zlayer_builder::ImageBuilder;
96//!
97//! #[tokio::main]
98//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
99//!     let image = ImageBuilder::new("./my-app").await?
100//!         .with_cache_dir("/var/cache/zlayer")  // Use persistent disk cache
101//!         .tag("myapp:latest")
102//!         .build()
103//!         .await?;
104//!
105//!     println!("Built image: {}", image.image_id);
106//!     Ok(())
107//! }
108//! ```
109
110use std::collections::HashMap;
111use std::path::{Path, PathBuf};
112use std::sync::mpsc;
113use std::sync::Arc;
114
115use tokio::fs;
116#[cfg(feature = "local-registry")]
117use tracing::warn;
118use tracing::{debug, info, instrument};
119#[cfg(feature = "local-registry")]
120use zlayer_paths::ZLayerDirs;
121
122use crate::backend::BuildBackend;
123#[cfg(feature = "local-registry")]
124use crate::buildah::BuildahCommand;
125use crate::buildah::BuildahExecutor;
126use crate::dockerfile::{Dockerfile, RunMount};
127use crate::error::{BuildError, Result};
128use crate::templates::{get_template, Runtime};
129use crate::tui::BuildEvent;
130
131#[cfg(feature = "cache")]
132use zlayer_registry::cache::BlobCacheBackend;
133
134#[cfg(feature = "local-registry")]
135use zlayer_registry::LocalRegistry;
136
137#[cfg(feature = "local-registry")]
138use zlayer_registry::import_image;
139
140/// Output from parsing a `ZImagefile` - either a Dockerfile for container builds
141/// or a WASM build result for WebAssembly builds.
142///
143/// Most `ZImagefile` modes (runtime, single-stage, multi-stage) produce a
144/// [`Dockerfile`] IR that is then built with buildah. WASM mode produces
145/// a compiled artifact directly, bypassing the container build pipeline.
146#[derive(Debug)]
147pub enum BuildOutput {
148    /// Standard container build - produces a Dockerfile to be built with buildah.
149    Dockerfile(Dockerfile),
150    /// WASM component build - already built, produces artifact path.
151    WasmArtifact {
152        /// Path to the compiled WASM binary.
153        wasm_path: PathBuf,
154        /// Path to the OCI artifact directory (if exported).
155        oci_path: Option<PathBuf>,
156        /// OCI manifest digest (e.g. `sha256:...`) for the exported artifact,
157        /// or `None` if export did not run (should always be `Some` when
158        /// `oci_path` is `Some`).
159        manifest_digest: Option<String>,
160        /// OCI artifact type (e.g. `application/vnd.wasm.component.v1+wasm`).
161        artifact_type: Option<String>,
162        /// Source language used.
163        language: String,
164        /// Whether optimization was applied.
165        optimized: bool,
166        /// Size of the output file in bytes.
167        size: u64,
168    },
169}
170
171/// Configuration for the layer cache backend.
172///
173/// This enum specifies which cache backend to use for storing and retrieving
174/// cached layers during builds. The cache feature must be enabled for this
175/// to be available.
176///
177/// # Example
178///
179/// ```no_run,ignore
180/// use zlayer_builder::{ImageBuilder, CacheBackendConfig};
181///
182/// # async fn example() -> Result<(), zlayer_builder::BuildError> {
183/// // Use persistent disk cache
184/// let builder = ImageBuilder::new("./my-app").await?
185///     .with_cache_config(CacheBackendConfig::Persistent {
186///         path: "/var/cache/zlayer".into(),
187///     })
188///     .tag("myapp:latest");
189/// # Ok(())
190/// # }
191/// ```
192#[cfg(feature = "cache")]
193#[derive(Debug, Clone, Default)]
194pub enum CacheBackendConfig {
195    /// In-memory cache (cleared when process exits).
196    ///
197    /// Useful for CI/CD environments where persistence isn't needed
198    /// but you want to avoid re-downloading base image layers within
199    /// a single build session.
200    #[default]
201    Memory,
202
203    /// Persistent disk-based cache using redb.
204    ///
205    /// Requires the `cache-persistent` feature. Layers are stored on disk
206    /// and persist across builds, significantly speeding up repeated builds.
207    #[cfg(feature = "cache-persistent")]
208    Persistent {
209        /// Path to the cache directory or database file.
210        /// If a directory, `blob_cache.redb` will be created inside it.
211        path: PathBuf,
212    },
213
214    /// S3-compatible object storage backend.
215    ///
216    /// Requires the `cache-s3` feature. Useful for distributed build systems
217    /// where multiple build machines need to share a cache.
218    #[cfg(feature = "cache-s3")]
219    S3 {
220        /// S3 bucket name
221        bucket: String,
222        /// AWS region (optional, uses SDK default if not set)
223        region: Option<String>,
224        /// Custom endpoint URL (for S3-compatible services like R2, B2, `MinIO`)
225        endpoint: Option<String>,
226        /// Key prefix for cached blobs (default: "zlayer/layers/")
227        prefix: Option<String>,
228    },
229}
230
231/// Built image information returned after a successful build
232#[derive(Debug, Clone)]
233pub struct BuiltImage {
234    /// Image ID (sha256:...)
235    pub image_id: String,
236    /// Applied tags
237    pub tags: Vec<String>,
238    /// Number of layers in the final image
239    pub layer_count: usize,
240    /// Total size in bytes (0 if not computed)
241    pub size: u64,
242    /// Build duration in milliseconds
243    pub build_time_ms: u64,
244    /// Whether this image is a manifest list (multi-arch).
245    pub is_manifest: bool,
246}
247
248/// Registry authentication credentials
249#[derive(Debug, Clone)]
250pub struct RegistryAuth {
251    /// Registry username
252    pub username: String,
253    /// Registry password or token
254    pub password: String,
255}
256
257impl RegistryAuth {
258    /// Create new registry authentication
259    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
260        Self {
261            username: username.into(),
262            password: password.into(),
263        }
264    }
265}
266
267/// Strategy for pulling the base image before building.
268///
269/// Controls the `--pull` flag passed to `buildah from`. The default is
270/// [`PullBaseMode::Newer`], matching the behaviour users expect from
271/// modern build tools: fast when nothing has changed, correct when the
272/// upstream base image has been republished.
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
274pub enum PullBaseMode {
275    /// Pull only if the registry has a newer version (`--pull=newer`).
276    /// Default behaviour.
277    #[default]
278    Newer,
279    /// Always pull, even if a local copy exists (`--pull=always`).
280    Always,
281    /// Never pull — use whatever is in local storage (no `--pull` flag passed).
282    Never,
283}
284
285/// Build options for customizing the image build process
286#[derive(Debug, Clone)]
287#[allow(clippy::struct_excessive_bools)]
288pub struct BuildOptions {
289    /// Dockerfile path (default: Dockerfile in context)
290    pub dockerfile: Option<PathBuf>,
291    /// `ZImagefile` path (alternative to Dockerfile)
292    pub zimagefile: Option<PathBuf>,
293    /// Use runtime template instead of Dockerfile
294    pub runtime: Option<Runtime>,
295    /// Build arguments (ARG values)
296    pub build_args: HashMap<String, String>,
297    /// Pipeline variables (`${VAR}`) expanded into the `ZImagefile` body
298    /// (`base:`, `run:`, ...) before parsing. Used by the pipeline executor to
299    /// parametrize a single `ZImagefile` set across e.g. Windows LTSC lines
300    /// (`--set LTSC=ltsc2025`). Empty for direct (non-pipeline) builds.
301    /// Only applied to `ZImagefile` bodies, never to Dockerfiles (whose
302    /// `${ARG}` syntax is parsed natively).
303    pub pipeline_vars: HashMap<String, String>,
304    /// Target stage for multi-stage builds
305    pub target: Option<String>,
306    /// Image tags to apply
307    pub tags: Vec<String>,
308    /// Disable layer caching
309    pub no_cache: bool,
310    /// Push to registry after build
311    pub push: bool,
312    /// Registry auth (if pushing)
313    pub registry_auth: Option<RegistryAuth>,
314    /// Squash all layers into one
315    pub squash: bool,
316    /// Image format (oci or docker)
317    pub format: Option<String>,
318    /// Enable buildah layer caching (--layers flag for `buildah build`).
319    /// Default: true
320    ///
321    /// Note: `ZLayer` uses manual container creation (`buildah from`, `buildah run`,
322    /// `buildah commit`) rather than `buildah build`, so this flag is reserved
323    /// for future use when/if we switch to `buildah build` (bud) command.
324    pub layers: bool,
325    /// Registry to pull cache from (--cache-from for `buildah build`).
326    ///
327    /// Note: This would be used with `buildah build --cache-from=<registry>`.
328    /// Currently `ZLayer` uses manual container creation, so this is reserved
329    /// for future implementation or for switching to `buildah build`.
330    ///
331    /// TODO: Implement remote cache support. This would require either:
332    /// 1. Switching to `buildah build` command which supports --cache-from natively
333    /// 2. Implementing custom layer caching with registry push/pull for intermediate layers
334    pub cache_from: Option<String>,
335    /// Registry to push cache to (--cache-to for `buildah build`).
336    ///
337    /// Note: This would be used with `buildah build --cache-to=<registry>`.
338    /// Currently `ZLayer` uses manual container creation, so this is reserved
339    /// for future implementation or for switching to `buildah build`.
340    ///
341    /// TODO: Implement remote cache support. This would require either:
342    /// 1. Switching to `buildah build` command which supports --cache-to natively
343    /// 2. Implementing custom layer caching with registry push/pull for intermediate layers
344    pub cache_to: Option<String>,
345    /// Maximum cache age (--cache-ttl for `buildah build`).
346    ///
347    /// Note: This would be used with `buildah build --cache-ttl=<duration>`.
348    /// Currently `ZLayer` uses manual container creation, so this is reserved
349    /// for future implementation or for switching to `buildah build`.
350    ///
351    /// TODO: Implement cache TTL support. This would require either:
352    /// 1. Switching to `buildah build` command which supports --cache-ttl natively
353    /// 2. Implementing custom cache expiration logic for our layer caching system
354    pub cache_ttl: Option<std::time::Duration>,
355    /// Cache backend configuration (requires `cache` feature).
356    ///
357    /// When configured, the builder will store layer data in the specified
358    /// cache backend for faster subsequent builds. This is separate from
359    /// buildah's native caching and operates at the `ZLayer` level.
360    ///
361    /// # Integration Points
362    ///
363    /// The cache backend is used at several points during the build:
364    ///
365    /// 1. **Before instruction execution**: Check if a cached layer exists
366    ///    for the (`instruction_hash`, `base_layer`) tuple
367    /// 2. **After instruction execution**: Store the resulting layer data
368    ///    in the cache for future builds
369    /// 3. **Base image layers**: Cache pulled base image layers to avoid
370    ///    re-downloading from registries
371    ///
372    /// TODO: Wire up cache lookups in the build loop once layer digests
373    /// are properly computed and tracked.
374    #[cfg(feature = "cache")]
375    pub cache_backend_config: Option<CacheBackendConfig>,
376    /// Default OCI/WASM-compatible registry to check for images before falling
377    /// back to Docker Hub qualification.
378    ///
379    /// When set, the builder will probe this registry for short image names
380    /// before qualifying them to `docker.io`. For example, if set to
381    /// `"git.example.com:5000"` and the `ZImagefile` uses `base: "myapp:latest"`,
382    /// the builder will check `git.example.com:5000/myapp:latest` first.
383    pub default_registry: Option<String>,
384    /// Default cache mounts injected into all RUN instructions.
385    /// These are merged with any step-level cache mounts (deduped by target path).
386    pub default_cache_mounts: Vec<RunMount>,
387    /// Number of retries for failed RUN steps (0 = no retries, default)
388    pub retries: u32,
389    /// Target platform for the build (e.g., "linux/amd64", "linux/arm64").
390    /// When set, `buildah from` pulls the platform-specific image variant.
391    pub platform: Option<String>,
392    /// Resolved target OS pin, mirrored from [`ImageBuilder::target_os`] right
393    /// before the backend runs. `None` = darwin-native (the macOS Seatbelt
394    /// sandbox provisions a Mac-native rootfs); `Some(Linux)`/`Some(Windows)` =
395    /// an explicit cross-OS target. The macOS sandbox backend reads this to
396    /// decide whether to provision a macOS-native toolchain for the base image
397    /// — it must NOT do so for an explicit Linux target.
398    pub target_os: Option<crate::backend::ImageOs>,
399    /// Target platforms for a multi-arch build (e.g. `["linux/amd64",
400    /// "linux/arm64"]`). When more than one is set, `build()` builds each
401    /// sequentially and assembles a manifest list. A single entry is treated
402    /// like `platform`. Empty = single-arch build governed by `platform`.
403    pub platforms: Vec<String>,
404    /// SHA-256 hash of the source Dockerfile/ZImagefile content.
405    ///
406    /// When set, the sandbox builder can skip a rebuild if the cached image
407    /// was produced from identical source content (content-based invalidation).
408    pub source_hash: Option<String>,
409    /// How to handle base-image pulling during `buildah from`.
410    ///
411    /// Default: [`PullBaseMode::Newer`] — only pull if the registry has a
412    /// newer version. Set to [`PullBaseMode::Always`] for CI builds that
413    /// must always refresh, or [`PullBaseMode::Never`] for offline builds.
414    pub pull: PullBaseMode,
415    /// Windows LTSC line to target for FROM image rewrites
416    /// (e.g. `"ltsc2022"`, `"ltsc2025"`).
417    ///
418    /// Only consumed by the Windows (HCS / WCOW) backend. When set, the
419    /// builder rewrites generic Docker Hub references (`ubuntu:24.04`,
420    /// `golang:1.24`, etc.) to the equivalent prebuilt Windows image via
421    /// `windows_image_resolver::rewrite_image_for_windows`. `None` means
422    /// the backend uses its built-in default (`ltsc2022`).
423    pub windows_ltsc: Option<String>,
424    /// Force `--net=host` on every `buildah run` for this build.
425    ///
426    /// When `true`, the buildah backend asks [`DockerfileTranslator`] to
427    /// emit `--net=host` on every translated `RUN` instruction, overriding
428    /// any per-instruction `network` value. This mirrors Docker's
429    /// `docker build --network=host` flag and bypasses buildah's CNI /
430    /// netavark plumbing entirely — the container shares the host's
431    /// network namespace.
432    ///
433    /// Default: `false`. Wired up from the top-level `zlayer
434    /// --host-network` CLI flag (see `bin/zlayer/src/cli.rs`).
435    pub host_network: bool,
436    /// Override the auto-detected build backend.
437    ///
438    /// `None` means "use `detect_backend()`'s default for the host × target
439    /// combination" (current behavior). `Some(kind)` forces that backend; if it's
440    /// unavailable for this host × target combination, the build fails with
441    /// `BuildError::NotSupported { operation: ... }`.
442    pub backend_override: Option<zlayer_types::builder::BuilderBackendKind>,
443}
444
445impl Default for BuildOptions {
446    fn default() -> Self {
447        Self {
448            dockerfile: None,
449            zimagefile: None,
450            runtime: None,
451            build_args: HashMap::new(),
452            pipeline_vars: HashMap::new(),
453            target: None,
454            tags: Vec::new(),
455            no_cache: false,
456            push: false,
457            registry_auth: None,
458            squash: false,
459            format: None,
460            layers: true,
461            cache_from: None,
462            cache_to: None,
463            cache_ttl: None,
464            #[cfg(feature = "cache")]
465            cache_backend_config: None,
466            default_registry: None,
467            default_cache_mounts: Vec::new(),
468            retries: 0,
469            platform: None,
470            target_os: None,
471            platforms: Vec::new(),
472            source_hash: None,
473            pull: PullBaseMode::default(),
474            windows_ltsc: None,
475            host_network: false,
476            backend_override: None,
477        }
478    }
479}
480
481/// Expand `${VAR}` references in a `ZImagefile` body using pipeline variables.
482///
483/// Mirrors `pipeline::executor::expand_tag_with_vars`: each `${key}` is replaced
484/// with its value; unknown references are left intact. Returns `content`
485/// unchanged when `vars` is empty (the common direct-build case), so this is a
486/// no-op for every non-pipeline caller.
487fn expand_zimage_vars(content: &str, vars: &std::collections::HashMap<String, String>) -> String {
488    if vars.is_empty() {
489        return content.to_string();
490    }
491    let mut result = content.to_string();
492    for (key, value) in vars {
493        result = result.replace(&format!("${{{key}}}"), value);
494    }
495    result
496}
497
498/// Map a platform string to the arch suffix used for per-arch member tags
499/// (`linux/amd64` → `amd64`, `linux/arm64/v8` → `arm64-v8`).
500fn platform_to_suffix(platform: &str) -> String {
501    let parts: Vec<&str> = platform.split('/').collect();
502    match parts.len() {
503        0 | 1 => platform.replace('/', "-"),
504        2 => parts[1].to_string(),
505        _ => format!("{}-{}", parts[1], parts[2]),
506    }
507}
508
509/// Verify the host can execute the target architecture: on Linux, a non-native
510/// arch needs a `binfmt_misc` qemu handler. Returns
511/// [`BuildError::BinfmtNotRegistered`] when it's missing so the caller gets a
512/// clear, actionable error instead of an opaque buildah `exec format error`.
513///
514/// The `Result` is meaningful only on Linux (where the binfmt handler can be
515/// missing); on every other target the body is an infallible `Ok(())`, so the
516/// `unnecessary_wraps` lint is allowed off-Linux while the signature stays
517/// uniform across platforms for the callers.
518#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))]
519fn verify_binfmt_for_platform(platform: &str) -> Result<()> {
520    #[cfg(target_os = "linux")]
521    {
522        // arch segment is the 2nd '/'-separated field (`os/arch[/variant]`).
523        let arch = platform.split('/').nth(1).unwrap_or(platform);
524        let native = std::env::consts::ARCH; // e.g. "x86_64", "aarch64"
525        let native_matches = matches!(
526            (native, arch),
527            ("x86_64", "amd64" | "386")
528                | ("aarch64", "arm64" | "arm64/v8")
529                | ("arm", "arm")
530                | ("powerpc64", "ppc64le")
531                | ("s390x", "s390x")
532                | ("riscv64", "riscv64")
533        );
534        if native_matches {
535            return Ok(());
536        }
537        // Map docker arch -> qemu-user-static binfmt handler name.
538        let qemu_arch = match arch {
539            "amd64" => "x86_64",
540            "386" => "i386",
541            "arm64" | "arm64/v8" => "aarch64",
542            "arm" => "arm",
543            "ppc64le" => "ppc64le",
544            "s390x" => "s390x",
545            "riscv64" => "riscv64",
546            other => other,
547        };
548        let handler = format!("/proc/sys/fs/binfmt_misc/qemu-{qemu_arch}");
549        if !std::path::Path::new(&handler).exists() {
550            return Err(BuildError::BinfmtNotRegistered {
551                platform: platform.to_string(),
552                qemu_arch: qemu_arch.to_string(),
553            });
554        }
555        Ok(())
556    }
557    #[cfg(not(target_os = "linux"))]
558    {
559        let _ = platform;
560        Ok(())
561    }
562}
563
564/// Discover a `ZImagefile` in the build context directory.
565///
566/// Resolution order matches user intuition:
567///
568/// 1. **Literal `ZImagefile`** in the context root — by far the most common
569///    layout (e.g. a one-image repo whose build file is just `ZImagefile`).
570/// 2. **Any `ZImagefile.<suffix>`** glob — the convention this repo itself
571///    uses (`ZImagefile.zlayer-node`, `ZImagefile.zlayer-manager`, …). If
572///    exactly one match exists, use it.
573/// 3. **Zero matches** → return `Ok(None)` so the caller falls through to the
574///    Dockerfile path.
575/// 4. **Multiple `ZImagefile.<suffix>` matches and no literal `ZImagefile`**
576///    → ambiguous; return an error telling the user to pick one with
577///    `-z <path>`. We don't silently pick "the first one" because that's a
578///    correctness landmine — a repo with `ZImagefile.prod` and
579///    `ZImagefile.dev` would otherwise build whichever entry the filesystem
580///    listed first.
581///
582/// The lookup is sync (a single `read_dir`) and doesn't follow symlinks
583/// beyond the standard `DirEntry::file_type()` semantics. Errors reading the
584/// directory propagate so the caller can attach a `BuildError::ContextRead`.
585///
586/// # Errors
587///
588/// Propagates the `read_dir` error when the context directory exists but
589/// cannot be read (a missing directory yields `Ok(None)`, not an error).
590pub fn find_context_zimagefile(context: &Path) -> std::io::Result<Option<PathBuf>> {
591    // (1) Literal `ZImagefile` wins outright.
592    let literal = context.join("ZImagefile");
593    if literal.exists() {
594        return Ok(Some(literal));
595    }
596
597    // (2) Scan for `ZImagefile.<suffix>` entries. We treat any non-empty
598    // suffix as a candidate; the suffix is opaque to the builder (it's just
599    // a disambiguator, like `Dockerfile.prod`).
600    let mut matches: Vec<PathBuf> = Vec::new();
601    let entries = match std::fs::read_dir(context) {
602        Ok(e) => e,
603        // Context dir doesn't exist / not readable: caller will surface a
604        // proper error elsewhere. Returning Ok(None) keeps this helper
605        // narrow.
606        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
607        Err(e) => return Err(e),
608    };
609    for entry in entries.flatten() {
610        let name = entry.file_name();
611        let Some(name_str) = name.to_str() else {
612            continue;
613        };
614        if let Some(rest) = name_str.strip_prefix("ZImagefile.") {
615            if !rest.is_empty() {
616                matches.push(entry.path());
617            }
618        }
619    }
620
621    match matches.len() {
622        0 => Ok(None),
623        1 => Ok(matches.pop()),
624        _ => {
625            matches.sort();
626            let names: Vec<String> = matches
627                .iter()
628                .map(|p| {
629                    p.file_name()
630                        .and_then(|n| n.to_str())
631                        .unwrap_or("?")
632                        .to_string()
633                })
634                .collect();
635            Err(std::io::Error::new(
636                std::io::ErrorKind::InvalidInput,
637                format!(
638                    "multiple ZImagefile candidates in {}: {} — pass `-z <path>` to pick one",
639                    context.display(),
640                    names.join(", "),
641                ),
642            ))
643        }
644    }
645}
646
647/// Image builder - orchestrates the full build process
648///
649/// `ImageBuilder` provides a fluent API for configuring and executing
650/// container image builds using buildah as the backend.
651///
652/// # Build Process
653///
654/// 1. Parse Dockerfile (or use runtime template)
655/// 2. Resolve target stages if specified
656/// 3. Build each stage sequentially:
657///    - Create working container from base image
658///    - Execute each instruction
659///    - Commit intermediate stages for COPY --from
660/// 4. Commit final image with tags
661/// 5. Push to registry if configured
662/// 6. Clean up intermediate containers
663///
664/// # Cache Backend Integration (requires `cache` feature)
665///
666/// When a cache backend is configured, the builder can store and retrieve
667/// cached layer data to speed up subsequent builds:
668///
669/// ```no_run,ignore
670/// use zlayer_builder::ImageBuilder;
671///
672/// let builder = ImageBuilder::new("./my-app").await?
673///     .with_cache_dir("/var/cache/zlayer")
674///     .tag("myapp:latest");
675/// ```
676pub struct ImageBuilder {
677    /// Build context directory
678    context: PathBuf,
679    /// Build options
680    options: BuildOptions,
681    /// Buildah executor (kept for backwards compatibility)
682    #[allow(dead_code)]
683    executor: BuildahExecutor,
684    /// Event sender for TUI updates
685    event_tx: Option<mpsc::Sender<BuildEvent>>,
686    /// Explicit target OS for this build.
687    ///
688    /// When `Some`, the backend was (or will be) detected for this OS and
689    /// it overrides any OS inferred from the `ZImagefile` (`os:` / `platform:`)
690    /// during `build()`. When `None`, the builder uses the OS inferred from
691    /// the parsed `ZImage` via `ZImage::resolve_target_os()`, falling back to
692    /// [`ImageOs::Linux`] when the `ZImagefile` has no OS hint either.
693    target_os: Option<crate::backend::ImageOs>,
694    /// Pluggable build backend (buildah, sandbox, etc.).
695    ///
696    /// When set, the `build()` method delegates to this backend instead of
697    /// using the inline buildah logic. Set automatically by `new()` via
698    /// `detect_backend()`, or explicitly via `with_backend()`.
699    backend: Option<Arc<dyn BuildBackend>>,
700    /// Cache backend for layer caching (requires `cache` feature).
701    ///
702    /// When set, the builder will attempt to retrieve cached layers before
703    /// executing instructions, and store results in the cache after execution.
704    ///
705    /// TODO: Implement cache lookups in the build loop. Currently the backend
706    /// is stored but not actively used during builds. Integration points:
707    /// - Check cache before executing RUN instructions
708    /// - Store layer data after successful instruction execution
709    /// - Cache base image layers pulled from registries
710    #[cfg(feature = "cache")]
711    cache_backend: Option<Arc<Box<dyn BlobCacheBackend>>>,
712    /// Local OCI registry for checking cached images before remote pulls.
713    ///
714    /// Stored as an `Arc` so the daemon's per-container Docker-compat socket
715    /// build path can hand in the SAME already-open `LocalRegistry` the daemon
716    /// serves from (via [`Self::with_local_registry_arc`]) rather than opening a
717    /// second on-disk handle — the blob cache it pairs with is single-process
718    /// exclusive, so a second open would deadlock.
719    #[cfg(feature = "local-registry")]
720    local_registry: Option<Arc<LocalRegistry>>,
721}
722
723impl ImageBuilder {
724    /// Create a new `ImageBuilder` with the given context directory
725    ///
726    /// The context directory should contain the Dockerfile (unless using
727    /// a runtime template) and any files that will be copied into the image.
728    ///
729    /// # Arguments
730    ///
731    /// * `context` - Path to the build context directory
732    ///
733    /// # Errors
734    ///
735    /// Returns an error if:
736    /// - The context directory does not exist
737    /// - Buildah is not installed or not accessible
738    ///
739    /// # Example
740    ///
741    /// ```no_run
742    /// use zlayer_builder::ImageBuilder;
743    ///
744    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
745    /// let builder = ImageBuilder::new("./my-project").await?;
746    /// # Ok(())
747    /// # }
748    /// ```
749    #[instrument(skip_all, fields(context = %context.as_ref().display()))]
750    pub async fn new(context: impl AsRef<Path>) -> Result<Self> {
751        Self::new_with_os(context, None).await
752    }
753
754    /// Create a new `ImageBuilder` with an explicit target OS.
755    ///
756    /// This is equivalent to [`ImageBuilder::new`] followed by
757    /// [`ImageBuilder::with_target_os`], but avoids the extra round-trip of
758    /// detecting a Linux backend first and throwing it away.
759    ///
760    /// Pass `None` to defer target-OS resolution to `build()` time, where
761    /// the effective OS is resolved from the `ZImagefile`'s `os:` or `platform:`
762    /// field (priority documented on [`crate::zimage::ZImage::resolve_target_os`]).
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if the context directory does not exist, or (on
767    /// Linux/Windows) if the buildah executor cannot be initialized.
768    #[instrument(skip_all, fields(context = %context.as_ref().display(), target_os = ?target_os))]
769    pub async fn new_with_os(
770        context: impl AsRef<Path>,
771        target_os: Option<crate::backend::ImageOs>,
772    ) -> Result<Self> {
773        let context = context.as_ref().to_path_buf();
774
775        // Verify context exists
776        if !context.exists() {
777            return Err(BuildError::ContextRead {
778                path: context,
779                source: std::io::Error::new(
780                    std::io::ErrorKind::NotFound,
781                    "Build context directory not found",
782                ),
783            });
784        }
785
786        // Detect the best available build backend for this platform. When
787        // `target_os` is None (caller hasn't decided yet), probe for the Linux
788        // backend as the common case; `build()` will re-detect if the parsed
789        // ZImagefile reveals a different target OS.
790        let detection_os = target_os.unwrap_or(crate::backend::ImageOs::Linux);
791        let backend = crate::backend::detect_backend(detection_os).await.ok();
792
793        // Initialize buildah executor.
794        // On macOS, if buildah is not found we fall back to a default executor
795        // (the backend will handle the actual build dispatch).
796        let executor = match BuildahExecutor::new_async().await {
797            Ok(exec) => exec,
798            #[cfg(target_os = "macos")]
799            Err(_) => {
800                info!("Buildah not found on macOS; backend will handle build dispatch");
801                BuildahExecutor::default()
802            }
803            // Windows has no native buildah. A Windows-image build dispatches to
804            // the native HCS backend (`detect_backend(Windows)` above) and a
805            // Linux-image build dispatches to the WSL2 backend — neither uses
806            // this host `BuildahExecutor` for the build itself (the WSL2 backend
807            // builds its own WSL-transport executor in `Wsl2BuildBackend::new`,
808            // and the HCS backend drives `WindowsBuilder`). The host executor is
809            // only ever touched by the local-registry OCI-export fallback in
810            // `build()`, which the HCS path must satisfy natively. So fall back
811            // to a default executor here rather than failing construction —
812            // mirrors the macOS sandbox path — and let `build()` re-detect /
813            // surface a precise backend error (e.g. "WSL2 distro not found")
814            // instead of a misleading "buildah not found" at construction time.
815            #[cfg(target_os = "windows")]
816            Err(_) => {
817                info!(
818                    "Buildah not available natively on Windows; backend (HCS/WSL2) \
819                     will handle build dispatch"
820                );
821                BuildahExecutor::default()
822            }
823            #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
824            Err(e) => return Err(e),
825        };
826
827        debug!("Created ImageBuilder for context: {}", context.display());
828
829        Ok(Self {
830            context,
831            options: BuildOptions::default(),
832            executor,
833            event_tx: None,
834            target_os,
835            backend,
836            #[cfg(feature = "cache")]
837            cache_backend: None,
838            #[cfg(feature = "local-registry")]
839            local_registry: None,
840        })
841    }
842
843    /// Override the target OS after construction, re-detecting the backend.
844    ///
845    /// Use this when the caller only learns the target OS *after* creating
846    /// the builder — for example, after parsing a `ZImagefile` to inspect its
847    /// `os:`/`platform:` fields. Passing the same OS that was already selected
848    /// at construction time is cheap (it still re-runs `detect_backend()`).
849    ///
850    /// # Errors
851    ///
852    /// Returns an error if `detect_backend(target_os)` fails for the current
853    /// host/target combination (e.g. Windows image requested on a Linux host).
854    pub async fn with_target_os(mut self, target_os: crate::backend::ImageOs) -> Result<Self> {
855        self.target_os = Some(target_os);
856        self.backend = Some(
857            crate::backend::detect_backend_with_options(target_os, Some(&self.options)).await?,
858        );
859        Ok(self)
860    }
861
862    /// Create an `ImageBuilder` with a custom buildah executor
863    ///
864    /// This is useful for testing or when you need to configure
865    /// the executor with specific storage options. The executor is
866    /// wrapped in a [`BuildahBackend`] so the build dispatches through
867    /// the [`BuildBackend`] trait.
868    ///
869    /// # Errors
870    ///
871    /// Returns an error if the context directory does not exist.
872    pub fn with_executor(context: impl AsRef<Path>, executor: BuildahExecutor) -> Result<Self> {
873        let context = context.as_ref().to_path_buf();
874
875        if !context.exists() {
876            return Err(BuildError::ContextRead {
877                path: context,
878                source: std::io::Error::new(
879                    std::io::ErrorKind::NotFound,
880                    "Build context directory not found",
881                ),
882            });
883        }
884
885        let backend: Arc<dyn BuildBackend> = Arc::new(
886            crate::backend::BuildahBackend::with_executor(executor.clone()),
887        );
888
889        Ok(Self {
890            context,
891            options: BuildOptions::default(),
892            executor,
893            event_tx: None,
894            target_os: None,
895            backend: Some(backend),
896            #[cfg(feature = "cache")]
897            cache_backend: None,
898            #[cfg(feature = "local-registry")]
899            local_registry: None,
900        })
901    }
902
903    /// Create an `ImageBuilder` with an explicit [`BuildBackend`].
904    ///
905    /// The backend is used for all build, push, tag, and manifest
906    /// operations. The internal `BuildahExecutor` is set to the default
907    /// (it is only used if no backend is set).
908    ///
909    /// # Errors
910    ///
911    /// Returns an error if the context directory does not exist.
912    pub fn with_backend(context: impl AsRef<Path>, backend: Arc<dyn BuildBackend>) -> Result<Self> {
913        let context = context.as_ref().to_path_buf();
914
915        if !context.exists() {
916            return Err(BuildError::ContextRead {
917                path: context,
918                source: std::io::Error::new(
919                    std::io::ErrorKind::NotFound,
920                    "Build context directory not found",
921                ),
922            });
923        }
924
925        Ok(Self {
926            context,
927            options: BuildOptions::default(),
928            executor: BuildahExecutor::default(),
929            event_tx: None,
930            target_os: None,
931            backend: Some(backend),
932            #[cfg(feature = "cache")]
933            cache_backend: None,
934            #[cfg(feature = "local-registry")]
935            local_registry: None,
936        })
937    }
938
939    /// Set a custom Dockerfile path
940    ///
941    /// By default, the builder looks for a file named `Dockerfile` in the
942    /// context directory. Use this method to specify a different path.
943    ///
944    /// # Example
945    ///
946    /// ```no_run
947    /// # use zlayer_builder::ImageBuilder;
948    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
949    /// let builder = ImageBuilder::new("./my-project").await?
950    ///     .dockerfile("./my-project/Dockerfile.prod");
951    /// # Ok(())
952    /// # }
953    /// ```
954    #[must_use]
955    pub fn dockerfile(mut self, path: impl AsRef<Path>) -> Self {
956        self.options.dockerfile = Some(path.as_ref().to_path_buf());
957        self
958    }
959
960    /// Set a custom `ZImagefile` path
961    ///
962    /// `ZImagefiles` are a YAML-based alternative to Dockerfiles. When set,
963    /// the builder will parse the `ZImagefile` and convert it to the internal
964    /// Dockerfile IR for execution.
965    ///
966    /// # Example
967    ///
968    /// ```no_run
969    /// # use zlayer_builder::ImageBuilder;
970    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
971    /// let builder = ImageBuilder::new("./my-project").await?
972    ///     .zimagefile("./my-project/ZImagefile");
973    /// # Ok(())
974    /// # }
975    /// ```
976    #[must_use]
977    pub fn zimagefile(mut self, path: impl AsRef<Path>) -> Self {
978        self.options.zimagefile = Some(path.as_ref().to_path_buf());
979        self
980    }
981
982    /// Use a runtime template instead of a Dockerfile
983    ///
984    /// Runtime templates provide pre-built Dockerfiles for common
985    /// development environments. When set, the Dockerfile option is ignored.
986    ///
987    /// # Example
988    ///
989    /// ```no_run
990    /// use zlayer_builder::{ImageBuilder, Runtime};
991    ///
992    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
993    /// let builder = ImageBuilder::new("./my-node-app").await?
994    ///     .runtime(Runtime::Node20);
995    /// # Ok(())
996    /// # }
997    /// ```
998    #[must_use]
999    pub fn runtime(mut self, runtime: Runtime) -> Self {
1000        self.options.runtime = Some(runtime);
1001        self
1002    }
1003
1004    /// Add a build argument
1005    ///
1006    /// Build arguments are passed to the Dockerfile and can be referenced
1007    /// using the `ARG` instruction.
1008    ///
1009    /// # Example
1010    ///
1011    /// ```no_run
1012    /// # use zlayer_builder::ImageBuilder;
1013    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1014    /// let builder = ImageBuilder::new("./my-project").await?
1015    ///     .build_arg("VERSION", "1.0.0")
1016    ///     .build_arg("DEBUG", "false");
1017    /// # Ok(())
1018    /// # }
1019    /// ```
1020    #[must_use]
1021    pub fn build_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1022        self.options.build_args.insert(key.into(), value.into());
1023        self
1024    }
1025
1026    /// Set multiple build arguments at once
1027    #[must_use]
1028    pub fn build_args(mut self, args: HashMap<String, String>) -> Self {
1029        self.options.build_args.extend(args);
1030        self
1031    }
1032
1033    /// Set pipeline variables expanded into the `ZImagefile` body before parse.
1034    ///
1035    /// `${VAR}` / `$VAR` references in a `ZImagefile`'s `base:`/`run:`/etc. are
1036    /// replaced with the matching value; unknown references are left untouched.
1037    /// Only `ZImagefile` bodies are expanded — Dockerfiles keep their native
1038    /// `${ARG}` semantics. Used by the pipeline executor to drive one
1039    /// `ZImagefile` set across variants (e.g. Windows `--set LTSC=ltsc2025`).
1040    #[must_use]
1041    pub fn pipeline_vars(mut self, vars: HashMap<String, String>) -> Self {
1042        self.options.pipeline_vars.extend(vars);
1043        self
1044    }
1045
1046    /// Set the Windows LTSC line to target for FROM image rewrites
1047    /// (e.g. `"ltsc2022"`, `"ltsc2025"`).
1048    ///
1049    /// Only consumed by the Windows (HCS / WCOW) backend. The pipeline
1050    /// executor wires this from the `LTSC` pipeline variable, so a
1051    /// `zlayer pipeline --set LTSC=ltsc2025` invocation flows down to the
1052    /// FROM rewriter in `windows_builder::start_build`. Has no effect on
1053    /// Linux / macOS backends.
1054    #[must_use]
1055    pub fn windows_ltsc(mut self, ltsc: impl Into<String>) -> Self {
1056        self.options.windows_ltsc = Some(ltsc.into());
1057        self
1058    }
1059
1060    /// Set the target stage for multi-stage builds
1061    ///
1062    /// When building a multi-stage Dockerfile, you can stop at a specific
1063    /// stage instead of building all stages.
1064    ///
1065    /// # Example
1066    ///
1067    /// ```no_run
1068    /// # use zlayer_builder::ImageBuilder;
1069    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1070    /// // Dockerfile:
1071    /// // FROM node:20 AS builder
1072    /// // ...
1073    /// // FROM node:20-slim AS runtime
1074    /// // ...
1075    ///
1076    /// let builder = ImageBuilder::new("./my-project").await?
1077    ///     .target("builder")
1078    ///     .tag("myapp:builder");
1079    /// # Ok(())
1080    /// # }
1081    /// ```
1082    #[must_use]
1083    pub fn target(mut self, stage: impl Into<String>) -> Self {
1084        self.options.target = Some(stage.into());
1085        self
1086    }
1087
1088    /// Add an image tag
1089    ///
1090    /// Tags are applied to the final image. You can add multiple tags.
1091    /// The first tag is used as the primary image name during commit.
1092    ///
1093    /// # Example
1094    ///
1095    /// ```no_run
1096    /// # use zlayer_builder::ImageBuilder;
1097    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1098    /// let builder = ImageBuilder::new("./my-project").await?
1099    ///     .tag("myapp:latest")
1100    ///     .tag("myapp:v1.0.0")
1101    ///     .tag("registry.example.com/myapp:v1.0.0");
1102    /// # Ok(())
1103    /// # }
1104    /// ```
1105    #[must_use]
1106    pub fn tag(mut self, tag: impl Into<String>) -> Self {
1107        self.options.tags.push(tag.into());
1108        self
1109    }
1110
1111    /// Disable layer caching
1112    ///
1113    /// When enabled, all layers are rebuilt from scratch even if
1114    /// they could be served from cache.
1115    ///
1116    /// Note: Currently this flag is tracked but not fully implemented in the
1117    /// build process. `ZLayer` uses manual container creation (`buildah from`,
1118    /// `buildah run`, `buildah commit`) which doesn't have built-in caching
1119    /// like `buildah build` does. Future work could implement layer-level
1120    /// caching by checking instruction hashes against previously built layers.
1121    #[must_use]
1122    pub fn no_cache(mut self) -> Self {
1123        self.options.no_cache = true;
1124        self
1125    }
1126
1127    /// Set the base-image pull strategy for the build.
1128    ///
1129    /// By default, `buildah from` is invoked with `--pull=newer`, so an
1130    /// up-to-date local base image is reused but a newer one on the
1131    /// registry will be fetched. Pass [`PullBaseMode::Always`] to force a
1132    /// fresh pull on every build, or [`PullBaseMode::Never`] to stay fully
1133    /// offline.
1134    #[must_use]
1135    pub fn pull(mut self, mode: PullBaseMode) -> Self {
1136        self.options.pull = mode;
1137        self
1138    }
1139
1140    /// Force `--net=host` on every `buildah run` emitted by this build.
1141    ///
1142    /// Mirrors Docker's `docker build --network=host` flag. When `on` is
1143    /// `true`, every translated `RUN` instruction is annotated with
1144    /// `RunNetwork::Host` regardless of any per-instruction `--network`
1145    /// directive, and the buildah backend emits `--net=host` on the
1146    /// resulting `buildah run` invocation. This bypasses buildah's CNI /
1147    /// netavark plumbing entirely (the container shares the host's
1148    /// network namespace).
1149    ///
1150    /// Wired from the top-level `zlayer --host-network` CLI flag.
1151    #[must_use]
1152    pub fn with_host_network(mut self, on: bool) -> Self {
1153        self.options.host_network = on;
1154        self
1155    }
1156
1157    /// Override the auto-detected build backend.
1158    ///
1159    /// `None` (the default) leaves backend selection to `detect_backend()`.
1160    /// `Some(kind)` forces that backend; if it is unavailable for the host ×
1161    /// target combination, the eventual build will fail with
1162    /// `BuildError::NotSupported`. Wired from the `zlayer build --backend`
1163    /// CLI flag.
1164    #[must_use]
1165    pub fn with_backend_override(
1166        mut self,
1167        backend: Option<zlayer_types::builder::BuilderBackendKind>,
1168    ) -> Self {
1169        self.options.backend_override = backend;
1170        self
1171    }
1172
1173    /// Enable or disable layer caching
1174    ///
1175    /// This controls the `--layers` flag for buildah. When enabled (default),
1176    /// buildah can cache and reuse intermediate layers.
1177    ///
1178    /// Note: `ZLayer` currently uses manual container creation (`buildah from`,
1179    /// `buildah run`, `buildah commit`) rather than `buildah build`, so this
1180    /// flag is reserved for future use when/if we switch to `buildah build`.
1181    ///
1182    /// # Example
1183    ///
1184    /// ```no_run
1185    /// # use zlayer_builder::ImageBuilder;
1186    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1187    /// let builder = ImageBuilder::new("./my-project").await?
1188    ///     .layers(false)  // Disable layer caching
1189    ///     .tag("myapp:latest");
1190    /// # Ok(())
1191    /// # }
1192    /// ```
1193    #[must_use]
1194    pub fn layers(mut self, enable: bool) -> Self {
1195        self.options.layers = enable;
1196        self
1197    }
1198
1199    /// Set registry to pull cache from
1200    ///
1201    /// This corresponds to buildah's `--cache-from` flag, which allows
1202    /// pulling cached layers from a remote registry to speed up builds.
1203    ///
1204    /// Note: `ZLayer` currently uses manual container creation (`buildah from`,
1205    /// `buildah run`, `buildah commit`) rather than `buildah build`, so this
1206    /// option is reserved for future implementation.
1207    ///
1208    /// TODO: Implement remote cache support. This would require either:
1209    /// 1. Switching to `buildah build` command which supports --cache-from natively
1210    /// 2. Implementing custom layer caching with registry pull for intermediate layers
1211    ///
1212    /// # Example
1213    ///
1214    /// ```no_run
1215    /// # use zlayer_builder::ImageBuilder;
1216    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1217    /// let builder = ImageBuilder::new("./my-project").await?
1218    ///     .cache_from("registry.example.com/myapp:cache")
1219    ///     .tag("myapp:latest");
1220    /// # Ok(())
1221    /// # }
1222    /// ```
1223    #[must_use]
1224    pub fn cache_from(mut self, registry: impl Into<String>) -> Self {
1225        self.options.cache_from = Some(registry.into());
1226        self
1227    }
1228
1229    /// Set registry to push cache to
1230    ///
1231    /// This corresponds to buildah's `--cache-to` flag, which allows
1232    /// pushing cached layers to a remote registry for future builds to use.
1233    ///
1234    /// Note: `ZLayer` currently uses manual container creation (`buildah from`,
1235    /// `buildah run`, `buildah commit`) rather than `buildah build`, so this
1236    /// option is reserved for future implementation.
1237    ///
1238    /// TODO: Implement remote cache support. This would require either:
1239    /// 1. Switching to `buildah build` command which supports --cache-to natively
1240    /// 2. Implementing custom layer caching with registry push for intermediate layers
1241    ///
1242    /// # Example
1243    ///
1244    /// ```no_run
1245    /// # use zlayer_builder::ImageBuilder;
1246    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1247    /// let builder = ImageBuilder::new("./my-project").await?
1248    ///     .cache_to("registry.example.com/myapp:cache")
1249    ///     .tag("myapp:latest");
1250    /// # Ok(())
1251    /// # }
1252    /// ```
1253    #[must_use]
1254    pub fn cache_to(mut self, registry: impl Into<String>) -> Self {
1255        self.options.cache_to = Some(registry.into());
1256        self
1257    }
1258
1259    /// Set maximum cache age
1260    ///
1261    /// This corresponds to buildah's `--cache-ttl` flag, which sets the
1262    /// maximum age for cached layers before they are considered stale.
1263    ///
1264    /// Note: `ZLayer` currently uses manual container creation (`buildah from`,
1265    /// `buildah run`, `buildah commit`) rather than `buildah build`, so this
1266    /// option is reserved for future implementation.
1267    ///
1268    /// TODO: Implement cache TTL support. This would require either:
1269    /// 1. Switching to `buildah build` command which supports --cache-ttl natively
1270    /// 2. Implementing custom cache expiration logic for our layer caching system
1271    ///
1272    /// # Example
1273    ///
1274    /// ```no_run
1275    /// # use zlayer_builder::ImageBuilder;
1276    /// # use std::time::Duration;
1277    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1278    /// let builder = ImageBuilder::new("./my-project").await?
1279    ///     .cache_ttl(Duration::from_secs(3600 * 24))  // 24 hours
1280    ///     .tag("myapp:latest");
1281    /// # Ok(())
1282    /// # }
1283    /// ```
1284    #[must_use]
1285    pub fn cache_ttl(mut self, ttl: std::time::Duration) -> Self {
1286        self.options.cache_ttl = Some(ttl);
1287        self
1288    }
1289
1290    /// Push the image to a registry after building
1291    ///
1292    /// # Arguments
1293    ///
1294    /// * `auth` - Registry authentication credentials
1295    ///
1296    /// # Example
1297    ///
1298    /// ```no_run
1299    /// use zlayer_builder::{ImageBuilder, RegistryAuth};
1300    ///
1301    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1302    /// let builder = ImageBuilder::new("./my-project").await?
1303    ///     .tag("registry.example.com/myapp:v1.0.0")
1304    ///     .push(RegistryAuth::new("user", "password"));
1305    /// # Ok(())
1306    /// # }
1307    /// ```
1308    #[must_use]
1309    pub fn push(mut self, auth: RegistryAuth) -> Self {
1310        self.options.push = true;
1311        self.options.registry_auth = Some(auth);
1312        self
1313    }
1314
1315    /// Enable pushing without authentication
1316    ///
1317    /// Use this for registries that don't require authentication
1318    /// (e.g., local registries, insecure registries).
1319    #[must_use]
1320    pub fn push_without_auth(mut self) -> Self {
1321        self.options.push = true;
1322        self.options.registry_auth = None;
1323        self
1324    }
1325
1326    /// Set a default OCI/WASM-compatible registry to check for images.
1327    ///
1328    /// When set, the builder will probe this registry for short image names
1329    /// before qualifying them to `docker.io`. For example, if set to
1330    /// `"git.example.com:5000"` and the `ZImagefile` uses `base: "myapp:latest"`,
1331    /// the builder will check `git.example.com:5000/myapp:latest` first.
1332    #[must_use]
1333    pub fn default_registry(mut self, registry: impl Into<String>) -> Self {
1334        self.options.default_registry = Some(registry.into());
1335        self
1336    }
1337
1338    /// Set a local OCI registry for image resolution.
1339    ///
1340    /// When set, the builder checks the local registry for cached images
1341    /// before pulling from remote registries.
1342    #[cfg(feature = "local-registry")]
1343    #[must_use]
1344    pub fn with_local_registry(mut self, registry: LocalRegistry) -> Self {
1345        self.local_registry = Some(Arc::new(registry));
1346        self
1347    }
1348
1349    /// Set a local OCI registry from an already-shared `Arc` handle.
1350    ///
1351    /// Like [`Self::with_local_registry`], but takes the registry by `Arc` so a
1352    /// caller that already holds the daemon's open `LocalRegistry` (the
1353    /// per-container Docker-compat socket build path) can wire in the EXACT same
1354    /// store the daemon serves from. Pair with [`Self::with_cache_backend`] using
1355    /// the daemon's shared blob-cache `Arc` so built images import into the live
1356    /// store without opening a second (single-process-exclusive) handle.
1357    #[cfg(feature = "local-registry")]
1358    #[must_use]
1359    pub fn with_local_registry_arc(mut self, registry: Arc<LocalRegistry>) -> Self {
1360        self.local_registry = Some(registry);
1361        self
1362    }
1363
1364    /// Squash all layers into a single layer
1365    ///
1366    /// This reduces image size but loses layer caching benefits.
1367    #[must_use]
1368    pub fn squash(mut self) -> Self {
1369        self.options.squash = true;
1370        self
1371    }
1372
1373    /// Set the image format
1374    ///
1375    /// Valid values are "oci" (default) or "docker".
1376    #[must_use]
1377    pub fn format(mut self, format: impl Into<String>) -> Self {
1378        self.options.format = Some(format.into());
1379        self
1380    }
1381
1382    /// Set default cache mounts to inject into all RUN instructions
1383    #[must_use]
1384    pub fn default_cache_mounts(mut self, mounts: Vec<RunMount>) -> Self {
1385        self.options.default_cache_mounts = mounts;
1386        self
1387    }
1388
1389    /// Set the number of retries for failed RUN steps
1390    #[must_use]
1391    pub fn retries(mut self, retries: u32) -> Self {
1392        self.options.retries = retries;
1393        self
1394    }
1395
1396    /// Set the target platform for cross-architecture builds.
1397    #[must_use]
1398    pub fn platform(mut self, platform: impl Into<String>) -> Self {
1399        self.options.platform = Some(platform.into());
1400        self
1401    }
1402
1403    /// Set multiple target platforms for a multi-arch manifest-list build.
1404    /// Accepts a comma-separated string (`"linux/amd64,linux/arm64"`) or
1405    /// individual values via repeated calls; empty/whitespace entries are
1406    /// dropped. More than one distinct platform triggers the manifest-list
1407    /// path in [`build`](Self::build).
1408    #[must_use]
1409    pub fn platforms<I, S>(mut self, platforms: I) -> Self
1410    where
1411        I: IntoIterator<Item = S>,
1412        S: Into<String>,
1413    {
1414        for p in platforms {
1415            let p = p.into();
1416            for part in p.split(',') {
1417                let part = part.trim();
1418                if !part.is_empty() {
1419                    self.options.platforms.push(part.to_string());
1420                }
1421            }
1422        }
1423        self
1424    }
1425
1426    /// Set a pre-computed source hash for content-based cache invalidation.
1427    ///
1428    /// When set, the sandbox builder can skip a full rebuild if the cached
1429    /// image was produced from identical source content.
1430    #[must_use]
1431    pub fn source_hash(mut self, hash: impl Into<String>) -> Self {
1432        self.options.source_hash = Some(hash.into());
1433        self
1434    }
1435
1436    /// Set an event sender for TUI progress updates
1437    ///
1438    /// Events will be sent as the build progresses, allowing you to
1439    /// display a progress UI or log build status.
1440    ///
1441    /// # Example
1442    ///
1443    /// ```no_run
1444    /// use zlayer_builder::{ImageBuilder, BuildEvent};
1445    /// use std::sync::mpsc;
1446    ///
1447    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1448    /// let (tx, rx) = mpsc::channel::<BuildEvent>();
1449    ///
1450    /// let builder = ImageBuilder::new("./my-project").await?
1451    ///     .tag("myapp:latest")
1452    ///     .with_events(tx);
1453    /// # Ok(())
1454    /// # }
1455    /// ```
1456    #[must_use]
1457    pub fn with_events(mut self, tx: mpsc::Sender<BuildEvent>) -> Self {
1458        self.event_tx = Some(tx);
1459        self
1460    }
1461
1462    /// Configure a persistent disk cache backend for layer caching.
1463    ///
1464    /// When configured, the builder will store layer data on disk at the
1465    /// specified path. This cache persists across builds and significantly
1466    /// speeds up repeated builds of similar images.
1467    ///
1468    /// Requires the `cache-persistent` feature to be enabled.
1469    ///
1470    /// # Arguments
1471    ///
1472    /// * `path` - Path to the cache directory. If a directory, creates
1473    ///   `blob_cache.redb` inside it. If a file path, uses it directly.
1474    ///
1475    /// # Example
1476    ///
1477    /// ```no_run,ignore
1478    /// use zlayer_builder::ImageBuilder;
1479    ///
1480    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1481    /// let builder = ImageBuilder::new("./my-project").await?
1482    ///     .with_cache_dir("/var/cache/zlayer")
1483    ///     .tag("myapp:latest");
1484    /// # Ok(())
1485    /// # }
1486    /// ```
1487    ///
1488    /// # Integration Status
1489    ///
1490    /// TODO: The cache backend is currently stored but not actively used
1491    /// during builds. Future work will wire up:
1492    /// - Cache lookups before executing RUN instructions
1493    /// - Storing layer data after successful execution
1494    /// - Caching base image layers from registry pulls
1495    #[cfg(feature = "cache-persistent")]
1496    #[must_use]
1497    pub fn with_cache_dir(mut self, path: impl AsRef<Path>) -> Self {
1498        self.options.cache_backend_config = Some(CacheBackendConfig::Persistent {
1499            path: path.as_ref().to_path_buf(),
1500        });
1501        debug!(
1502            "Configured persistent cache at: {}",
1503            path.as_ref().display()
1504        );
1505        self
1506    }
1507
1508    /// Configure an in-memory cache backend for layer caching.
1509    ///
1510    /// The in-memory cache is cleared when the process exits, but can
1511    /// speed up builds within a single session by caching intermediate
1512    /// layers and avoiding redundant operations.
1513    ///
1514    /// Requires the `cache` feature to be enabled.
1515    ///
1516    /// # Example
1517    ///
1518    /// ```no_run,ignore
1519    /// use zlayer_builder::ImageBuilder;
1520    ///
1521    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1522    /// let builder = ImageBuilder::new("./my-project").await?
1523    ///     .with_memory_cache()
1524    ///     .tag("myapp:latest");
1525    /// # Ok(())
1526    /// # }
1527    /// ```
1528    ///
1529    /// # Integration Status
1530    ///
1531    /// TODO: The cache backend is currently stored but not actively used
1532    /// during builds. See `with_cache_dir` for integration status details.
1533    #[cfg(feature = "cache")]
1534    #[must_use]
1535    pub fn with_memory_cache(mut self) -> Self {
1536        self.options.cache_backend_config = Some(CacheBackendConfig::Memory);
1537        debug!("Configured in-memory cache");
1538        self
1539    }
1540
1541    /// Configure an S3-compatible storage backend for layer caching.
1542    ///
1543    /// This is useful for distributed build systems where multiple build
1544    /// machines need to share a layer cache. Supports AWS S3, Cloudflare R2,
1545    /// Backblaze B2, `MinIO`, and other S3-compatible services.
1546    ///
1547    /// Requires the `cache-s3` feature to be enabled.
1548    ///
1549    /// # Arguments
1550    ///
1551    /// * `bucket` - S3 bucket name
1552    /// * `region` - AWS region (optional, uses SDK default if not set)
1553    ///
1554    /// # Example
1555    ///
1556    /// ```no_run,ignore
1557    /// use zlayer_builder::ImageBuilder;
1558    ///
1559    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1560    /// let builder = ImageBuilder::new("./my-project").await?
1561    ///     .with_s3_cache("my-build-cache", Some("us-west-2"))
1562    ///     .tag("myapp:latest");
1563    /// # Ok(())
1564    /// # }
1565    /// ```
1566    ///
1567    /// # Integration Status
1568    ///
1569    /// TODO: The cache backend is currently stored but not actively used
1570    /// during builds. See `with_cache_dir` for integration status details.
1571    #[cfg(feature = "cache-s3")]
1572    #[must_use]
1573    pub fn with_s3_cache(mut self, bucket: impl Into<String>, region: Option<String>) -> Self {
1574        self.options.cache_backend_config = Some(CacheBackendConfig::S3 {
1575            bucket: bucket.into(),
1576            region,
1577            endpoint: None,
1578            prefix: None,
1579        });
1580        debug!("Configured S3 cache");
1581        self
1582    }
1583
1584    /// Configure an S3-compatible storage backend with custom endpoint.
1585    ///
1586    /// Use this method for S3-compatible services that require a custom
1587    /// endpoint URL (e.g., Cloudflare R2, `MinIO`, local development).
1588    ///
1589    /// Requires the `cache-s3` feature to be enabled.
1590    ///
1591    /// # Arguments
1592    ///
1593    /// * `bucket` - S3 bucket name
1594    /// * `endpoint` - Custom endpoint URL
1595    /// * `region` - Region (required for some S3-compatible services)
1596    ///
1597    /// # Example
1598    ///
1599    /// ```no_run,ignore
1600    /// use zlayer_builder::ImageBuilder;
1601    ///
1602    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1603    /// // Cloudflare R2
1604    /// let builder = ImageBuilder::new("./my-project").await?
1605    ///     .with_s3_cache_endpoint(
1606    ///         "my-bucket",
1607    ///         "https://accountid.r2.cloudflarestorage.com",
1608    ///         Some("auto".to_string()),
1609    ///     )
1610    ///     .tag("myapp:latest");
1611    /// # Ok(())
1612    /// # }
1613    /// ```
1614    #[cfg(feature = "cache-s3")]
1615    #[must_use]
1616    pub fn with_s3_cache_endpoint(
1617        mut self,
1618        bucket: impl Into<String>,
1619        endpoint: impl Into<String>,
1620        region: Option<String>,
1621    ) -> Self {
1622        self.options.cache_backend_config = Some(CacheBackendConfig::S3 {
1623            bucket: bucket.into(),
1624            region,
1625            endpoint: Some(endpoint.into()),
1626            prefix: None,
1627        });
1628        debug!("Configured S3 cache with custom endpoint");
1629        self
1630    }
1631
1632    /// Configure a custom cache backend configuration.
1633    ///
1634    /// This is the most flexible way to configure the cache backend,
1635    /// allowing full control over all cache settings.
1636    ///
1637    /// Requires the `cache` feature to be enabled.
1638    ///
1639    /// # Example
1640    ///
1641    /// ```no_run,ignore
1642    /// use zlayer_builder::{ImageBuilder, CacheBackendConfig};
1643    ///
1644    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1645    /// let builder = ImageBuilder::new("./my-project").await?
1646    ///     .with_cache_config(CacheBackendConfig::Memory)
1647    ///     .tag("myapp:latest");
1648    /// # Ok(())
1649    /// # }
1650    /// ```
1651    #[cfg(feature = "cache")]
1652    #[must_use]
1653    pub fn with_cache_config(mut self, config: CacheBackendConfig) -> Self {
1654        self.options.cache_backend_config = Some(config);
1655        debug!("Configured custom cache backend");
1656        self
1657    }
1658
1659    /// Set an already-initialized cache backend directly.
1660    ///
1661    /// This is useful when you have a pre-configured cache backend instance
1662    /// that you want to share across multiple builders or when you need
1663    /// fine-grained control over cache initialization.
1664    ///
1665    /// Requires the `cache` feature to be enabled.
1666    ///
1667    /// # Example
1668    ///
1669    /// ```no_run,ignore
1670    /// use zlayer_builder::ImageBuilder;
1671    /// use zlayer_registry::cache::BlobCache;
1672    /// use std::sync::Arc;
1673    ///
1674    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
1675    /// let cache = Arc::new(Box::new(BlobCache::new()?) as Box<dyn zlayer_registry::cache::BlobCacheBackend>);
1676    ///
1677    /// let builder = ImageBuilder::new("./my-project").await?
1678    ///     .with_cache_backend(cache)
1679    ///     .tag("myapp:latest");
1680    /// # Ok(())
1681    /// # }
1682    /// ```
1683    #[cfg(feature = "cache")]
1684    #[must_use]
1685    pub fn with_cache_backend(mut self, backend: Arc<Box<dyn BlobCacheBackend>>) -> Self {
1686        self.cache_backend = Some(backend);
1687        debug!("Configured pre-initialized cache backend");
1688        self
1689    }
1690
1691    /// Build each requested platform sequentially (cross-arch can be flaky in
1692    /// parallel) and assemble a buildah manifest list referencing all of them.
1693    /// When `options.push` is set, push the manifest list to the registry.
1694    async fn build_manifest_list(
1695        &self,
1696        dockerfile: &crate::dockerfile::Dockerfile,
1697        start_time: std::time::Instant,
1698    ) -> Result<BuiltImage> {
1699        let backend = self
1700            .backend
1701            .as_ref()
1702            .ok_or_else(|| BuildError::BuildahNotFound {
1703                message: "No build backend configured".into(),
1704            })?;
1705
1706        let real_tags = self.options.tags.clone();
1707        let manifest_name =
1708            real_tags
1709                .first()
1710                .cloned()
1711                .ok_or_else(|| BuildError::InvalidInstruction {
1712                    instruction: "--platform".into(),
1713                    reason: "multi-arch build requires at least one tag (-t)".into(),
1714                })?;
1715
1716        let mut arch_tags: Vec<String> = Vec::new();
1717        let mut total_layers = 0usize;
1718        let mut total_size = 0u64;
1719
1720        for platform in &self.options.platforms {
1721            verify_binfmt_for_platform(platform)?;
1722            let suffix = platform_to_suffix(platform);
1723            let platform_tags: Vec<String> =
1724                real_tags.iter().map(|t| format!("{t}-{suffix}")).collect();
1725
1726            let mut opts = self.options.clone();
1727            opts.platform = Some(platform.clone());
1728            opts.platforms = Vec::new();
1729            opts.tags.clone_from(&platform_tags);
1730            opts.push = false; // push the assembled manifest list, not per-arch images
1731
1732            info!("Building manifest member for platform {platform}");
1733            let built = backend
1734                .build_image(&self.context, dockerfile, &opts, self.event_tx.clone())
1735                .await?;
1736            total_layers += built.layer_count;
1737            total_size += built.size;
1738            if let Some(first) = platform_tags.first() {
1739                arch_tags.push(first.clone());
1740            }
1741        }
1742
1743        // Assemble the manifest list under the first tag, add each arch member,
1744        // then apply any remaining tags to the list.
1745        info!("Creating manifest list {manifest_name}");
1746        backend.manifest_create(&manifest_name).await?;
1747        for arch_tag in &arch_tags {
1748            backend.manifest_add(&manifest_name, arch_tag).await?;
1749        }
1750        for tag in real_tags.iter().skip(1) {
1751            backend.tag_image(&manifest_name, tag).await?;
1752        }
1753
1754        if self.options.push {
1755            for tag in &real_tags {
1756                info!("Pushing manifest list {tag}");
1757                backend
1758                    .manifest_push(
1759                        tag,
1760                        &format!("docker://{tag}"),
1761                        self.options.registry_auth.as_ref(),
1762                    )
1763                    .await?;
1764            }
1765        }
1766
1767        #[allow(clippy::cast_possible_truncation)]
1768        let build_time_ms = start_time.elapsed().as_millis() as u64;
1769        Ok(BuiltImage {
1770            image_id: manifest_name,
1771            tags: real_tags,
1772            layer_count: total_layers,
1773            size: total_size,
1774            build_time_ms,
1775            is_manifest: true,
1776        })
1777    }
1778
1779    /// Run the build
1780    ///
1781    /// This executes the complete build process:
1782    /// 1. Parse Dockerfile or load runtime template
1783    /// 2. Build all required stages
1784    /// 3. Commit and tag the final image
1785    /// 4. Push to registry if configured
1786    /// 5. Clean up intermediate containers
1787    ///
1788    /// # Errors
1789    ///
1790    /// Returns an error if:
1791    /// - Dockerfile parsing fails
1792    /// - A buildah command fails
1793    /// - Target stage is not found
1794    /// - Registry push fails
1795    ///
1796    /// # Panics
1797    ///
1798    /// Panics if an instruction output is missing after all retry attempts (internal invariant).
1799    #[instrument(skip(self), fields(context = %self.context.display()))]
1800    #[allow(clippy::too_many_lines)]
1801    pub async fn build(mut self) -> Result<BuiltImage> {
1802        let start_time = std::time::Instant::now();
1803
1804        info!("Starting build in context: {}", self.context.display());
1805
1806        // 0. Resolve the effective target OS from the priority chain when the
1807        //    caller did not pin one explicitly. Re-detects the backend if the
1808        //    resolved OS differs from the one we initially probed (Linux). A
1809        //    pinned `target_os` wins and skips this resolution entirely.
1810        self.resolve_target_os_and_backend().await?;
1811
1812        // Mirror the resolved OS pin into the options so the backend can read
1813        // it. The macOS Seatbelt sandbox uses this to gate base-image toolchain
1814        // provisioning to darwin-native builds only (`None`).
1815        self.options.target_os = self.target_os;
1816
1817        // 1. Get build output (Dockerfile IR or WASM artifact)
1818        let build_output = self.get_build_output().await?;
1819
1820        // If this is a WASM build, return early with the artifact info.
1821        if let BuildOutput::WasmArtifact {
1822            wasm_path,
1823            // `oci_path` drives the optional push branch below; when the
1824            // `local-registry` feature is off the push branch is compiled
1825            // out, so the binding is unused.
1826            #[cfg_attr(not(feature = "local-registry"), allow(unused_variables))]
1827            oci_path,
1828            manifest_digest,
1829            artifact_type: _,
1830            language,
1831            optimized,
1832            size,
1833        } = build_output
1834        {
1835            #[allow(clippy::cast_possible_truncation)]
1836            let build_time_ms = start_time.elapsed().as_millis() as u64;
1837
1838            // Prefer a user tag as the image id; otherwise fall back to the
1839            // OCI manifest digest (sha256:...), which is what WASM tooling
1840            // references in `oci-archive:` / `oci:` URIs. As a last resort
1841            // (no tag, no digest — only possible if export somehow produced
1842            // no digest) use a `wasm-path:` marker so downstream code can
1843            // tell this was a WASM build.
1844            let image_id = if let Some(tag) = self.options.tags.first() {
1845                tag.clone()
1846            } else if let Some(digest) = manifest_digest.as_ref() {
1847                format!("wasm:{digest}")
1848            } else {
1849                format!("wasm-path:{}", wasm_path.display())
1850            };
1851
1852            // Push WASM OCI artifact(s) to the remote registry if the user
1853            // both supplied tags and requested a push (e.g. `zlayer build
1854            // -t ghcr.io/org/mod:v1 --push`). Mirrors the container flow at
1855            // `BuildahBackend::build_image` where `options.push` drives
1856            // `push_image_internal` for each tag.
1857            //
1858            // Gated on `local-registry` because `ImagePuller::push_wasm` is
1859            // behind the `zlayer-registry/local` feature, matching the other
1860            // push-to-registry sites in this crate.
1861            #[cfg(feature = "local-registry")]
1862            if let Some(oci_dir) = oci_path
1863                .as_ref()
1864                .filter(|_| self.options.push && !self.options.tags.is_empty())
1865            {
1866                self.push_wasm_oci(&wasm_path, oci_dir).await?;
1867            }
1868
1869            self.send_event(BuildEvent::BuildComplete {
1870                image_id: image_id.clone(),
1871            });
1872
1873            info!(
1874                "WASM build completed in {}ms: {} ({}, {} bytes, optimized={}, image_id={})",
1875                build_time_ms,
1876                wasm_path.display(),
1877                language,
1878                size,
1879                optimized,
1880                image_id,
1881            );
1882
1883            return Ok(BuiltImage {
1884                image_id,
1885                tags: self.options.tags.clone(),
1886                layer_count: 1,
1887                size,
1888                build_time_ms,
1889                is_manifest: false,
1890            });
1891        }
1892
1893        // Extract the Dockerfile from the BuildOutput.
1894        let BuildOutput::Dockerfile(mut dockerfile) = build_output else {
1895            unreachable!("WasmArtifact case handled above");
1896        };
1897        // Docker semantics: ARGs declared before the first FROM participate
1898        // in FROM-line expansion (`FROM ${BASE_IMAGE}`), with build args
1899        // overriding their defaults. The parser keeps such targets as raw
1900        // stage strings (it has no build args), so resolve them here — for
1901        // every struct-consuming backend — or multi-stage Dockerfiles with
1902        // parameterised bases fail with "Stage '${...}' not found".
1903        dockerfile.resolve_from_args(&self.options.build_args);
1904        debug!("Parsed Dockerfile with {} stages", dockerfile.stages.len());
1905
1906        // L-5: Static guard — catch `RUN choco install ...` /
1907        // `RUN winget install ...` on a nanoserver base image before we hand
1908        // the Dockerfile off to the backend. Nanoserver ships no package
1909        // manager, so without this check the build fails deep inside buildah
1910        // / HCS with an opaque "`choco` is not recognized" message.
1911        //
1912        // The validator is a pure AST walk; it runs regardless of the
1913        // resolved target OS because a Dockerfile pinning a Windows base
1914        // should be diagnosed the same way on a Linux build host doing a
1915        // cross-OS build as on a Windows host.
1916        if let Err(err) = crate::windows::deps::validate_dockerfile(&dockerfile) {
1917            return Err(BuildError::InvalidInstruction {
1918                instruction: "RUN".to_string(),
1919                reason: err.to_string(),
1920            });
1921        }
1922
1923        // Single-platform convenience: a one-entry `platforms` list behaves like
1924        // `platform`. Promote it so the single-build path below picks it up.
1925        if self.options.platform.is_none() && self.options.platforms.len() == 1 {
1926            self.options.platform = Some(self.options.platforms[0].clone());
1927        }
1928        // Multi-arch: build each platform and assemble a manifest list.
1929        if self.options.platforms.len() > 1 {
1930            return self.build_manifest_list(&dockerfile, start_time).await;
1931        }
1932
1933        // Delegate the build to the backend.
1934        let backend = self
1935            .backend
1936            .as_ref()
1937            .ok_or_else(|| BuildError::BuildahNotFound {
1938                message: "No build backend configured".into(),
1939            })?;
1940
1941        info!("Delegating build to {} backend", backend.name());
1942        let built = backend
1943            .build_image(
1944                &self.context,
1945                &dockerfile,
1946                &self.options,
1947                self.event_tx.clone(),
1948            )
1949            .await?;
1950
1951        // Import the built image into ZLayer's local registry and blob cache
1952        // so the runtime can find it without pulling from a remote registry.
1953        //
1954        // A user who wired up a local registry clearly wants built images to
1955        // live there — if the import fails (almost always EACCES on the
1956        // registry dir for an unprivileged user), bail with the registry path
1957        // in the message instead of silently producing a build that the
1958        // daemon can't find.
1959        #[cfg(feature = "local-registry")]
1960        if let Some(ref registry) = self.local_registry {
1961            if !built.tags.is_empty() {
1962                let tmp_dir = ZLayerDirs::system_default().tmp();
1963                std::fs::create_dir_all(&tmp_dir).ok();
1964                let tmp_path = tmp_dir.join(format!(
1965                    "zlayer-build-{}-{}.tar",
1966                    std::process::id(),
1967                    start_time.elapsed().as_nanos()
1968                ));
1969
1970                // Export the built image to an OCI archive for the local-registry
1971                // import. Backend-owned: a non-buildah backend (the macOS Seatbelt
1972                // sandbox) assembles the archive natively from its rootfs; backends
1973                // that decline (`Ok(false)`) fall back to `buildah push …
1974                // oci-archive:` via the buildah executor. This is what lets a
1975                // buildah-less macOS host populate ~/.zlayer/registry.
1976                let export_tag = &built.tags[0];
1977                if !backend.export_oci_archive(export_tag, &tmp_path).await? {
1978                    let dest = format!("oci-archive:{}", tmp_path.display());
1979                    let push_cmd = BuildahCommand::push_to(export_tag, &dest);
1980
1981                    self.executor
1982                        .execute_checked(&push_cmd)
1983                        .await
1984                        .map_err(|e| BuildError::RegistryError {
1985                            message: format!(
1986                                "failed to export image to OCI archive for local registry \
1987                                 import at {}: {e}",
1988                                registry.root().display()
1989                            ),
1990                        })?;
1991                }
1992
1993                // Resolve the blob cache backend (if available).
1994                let blob_cache: Option<&dyn zlayer_registry::cache::BlobCacheBackend> =
1995                    self.cache_backend.as_ref().map(|arc| arc.as_ref().as_ref());
1996
1997                let import_result = async {
1998                    for tag in &built.tags {
1999                        let info = import_image(
2000                            registry.as_ref(),
2001                            &tmp_path,
2002                            Some(tag.as_str()),
2003                            blob_cache,
2004                        )
2005                        .await
2006                        .map_err(|e| BuildError::RegistryError {
2007                            message: format!(
2008                                "failed to import '{tag}' into local registry at {}: {e}",
2009                                registry.root().display()
2010                            ),
2011                        })?;
2012                        info!(
2013                            tag = %tag,
2014                            digest = %info.digest,
2015                            "Imported into local registry"
2016                        );
2017                    }
2018                    Ok::<(), BuildError>(())
2019                }
2020                .await;
2021
2022                // Clean up the temporary archive regardless of whether the
2023                // import succeeded (best-effort; warn on failure).
2024                if let Err(e) = fs::remove_file(&tmp_path).await {
2025                    warn!(path = %tmp_path.display(), error = %e, "Failed to remove temp OCI archive");
2026                }
2027
2028                import_result?;
2029            }
2030        }
2031
2032        Ok(built)
2033    }
2034
2035    /// Resolve the effective target OS for this build and re-detect the
2036    /// backend when it differs from what was probed at construction.
2037    ///
2038    /// Priority (highest first):
2039    /// 1. `self.target_os` — explicit pin from the caller (e.g. CLI `--platform`).
2040    /// 2. `ZImage::resolve_target_os()` — `os:` field, else OS parsed from
2041    ///    the `platform:` field of the `ZImagefile`.
2042    /// 3. [`ImageOs::Linux`] — the historical default, applied whenever the
2043    ///    `ZImagefile` has neither hint and the caller didn't pin an OS.
2044    ///
2045    /// The runtime-template and plain-Dockerfile paths never carry an OS
2046    /// hint, so they fall through to the caller's pin or the default.
2047    async fn resolve_target_os_and_backend(&mut self) -> Result<()> {
2048        // Explicit pin always wins: the backend was already detected for
2049        // this OS by `new_with_os`/`with_target_os`. But the caller may
2050        // have set `backend_override` AFTER construction (via
2051        // `with_backend_override`), in which case the cached backend was
2052        // selected without that hint — re-detect so the override is honored.
2053        if let Some(target_os) = self.target_os {
2054            if self.options.backend_override.is_some() {
2055                self.backend = Some(
2056                    crate::backend::detect_backend_with_options(target_os, Some(&self.options))
2057                        .await?,
2058                );
2059            }
2060            return Ok(());
2061        }
2062
2063        // Peek at the ZImagefile (if the caller pointed us at one, or if one
2064        // lives in the context dir). We only inspect the OS-related fields so
2065        // a malformed ZImagefile body defers its error to `get_build_output`.
2066        // Auto-detection accepts both a literal `ZImagefile` and any
2067        // `ZImagefile.<suffix>`. Read errors / ambiguity here are non-fatal
2068        // for OS peeking — `get_build_output` will surface them with a
2069        // proper `BuildError::ContextRead`.
2070        let zimage_path = self
2071            .options
2072            .zimagefile
2073            .clone()
2074            .or_else(|| find_context_zimagefile(&self.context).ok().flatten());
2075
2076        let Some(path) = zimage_path else {
2077            // No ZImagefile — Dockerfile / runtime template paths have no OS
2078            // metadata, so the initial Linux detection stands. If the caller
2079            // set a backend_override after construction, re-resolve so the
2080            // cached default backend is replaced.
2081            if self.options.backend_override.is_some() {
2082                self.backend = Some(
2083                    crate::backend::detect_backend_with_options(
2084                        crate::backend::ImageOs::Linux,
2085                        Some(&self.options),
2086                    )
2087                    .await?,
2088                );
2089            }
2090            return Ok(());
2091        };
2092
2093        // Let `get_build_output()` surface any real read / parse errors.
2094        let Ok(content) = fs::read_to_string(&path).await else {
2095            return Ok(());
2096        };
2097        let content = expand_zimage_vars(&content, &self.options.pipeline_vars);
2098        let Ok(zimage) = crate::zimage::parse_zimagefile(&content) else {
2099            return Ok(());
2100        };
2101
2102        // Resolve the effective target OS. Explicit ZImagefile `os:`/`platform:`
2103        // hints win; when absent, sniff the produced binary's OS (mirrors the
2104        // CLI front-end). Both feed the same backend-selection path below, so a
2105        // binary-detected OS is treated exactly like an explicit hint.
2106        let resolved_os = zimage
2107            .resolve_target_os()
2108            .or_else(|| crate::zimage::detect_image_os_from_binary(&zimage, &self.context));
2109
2110        if let Some(resolved) = resolved_os {
2111            // Re-detect only if the resolved OS differs from the one we
2112            // probed at construction. `new_with_os(None)` probes Linux, so
2113            // the common Linux case short-circuits — unless the caller
2114            // set a backend_override after construction, in which case we
2115            // must re-detect even for the initial OS to apply the override.
2116            let initial = crate::backend::ImageOs::Linux;
2117            if resolved != initial || self.options.backend_override.is_some() {
2118                info!(
2119                    "Re-detecting build backend for target OS {:?} (inferred from ZImagefile)",
2120                    resolved
2121                );
2122                self.backend = Some(
2123                    crate::backend::detect_backend_with_options(resolved, Some(&self.options))
2124                        .await?,
2125                );
2126            }
2127            self.target_os = Some(resolved);
2128        } else if self.options.backend_override.is_some() {
2129            // ZImagefile present but resolves to no explicit OS — apply the
2130            // override against the Linux default that was cached.
2131            self.backend = Some(
2132                crate::backend::detect_backend_with_options(
2133                    crate::backend::ImageOs::Linux,
2134                    Some(&self.options),
2135                )
2136                .await?,
2137            );
2138        }
2139
2140        Ok(())
2141    }
2142
2143    /// Detection order:
2144    /// 1. If `runtime` is set -> use template string -> parse as Dockerfile
2145    /// 2. If `zimagefile` is explicitly set -> read & parse `ZImagefile` -> convert
2146    /// 3. If a file called `ZImagefile` exists in the context dir -> same as (2)
2147    /// 4. Fall back to reading a Dockerfile (from `dockerfile` option or default)
2148    ///
2149    /// Returns [`BuildOutput::Dockerfile`] for container builds or
2150    /// [`BuildOutput::WasmArtifact`] for WASM builds.
2151    async fn get_build_output(&self) -> Result<BuildOutput> {
2152        // (a) Runtime template takes highest priority.
2153        if let Some(runtime) = &self.options.runtime {
2154            debug!("Using runtime template: {}", runtime);
2155            let content = get_template(*runtime);
2156            return Ok(BuildOutput::Dockerfile(Dockerfile::parse(content)?));
2157        }
2158
2159        // (b) Explicit ZImagefile path.
2160        if let Some(ref zimage_path) = self.options.zimagefile {
2161            debug!("Reading ZImagefile: {}", zimage_path.display());
2162            let content =
2163                fs::read_to_string(zimage_path)
2164                    .await
2165                    .map_err(|e| BuildError::ContextRead {
2166                        path: zimage_path.clone(),
2167                        source: e,
2168                    })?;
2169            let content = expand_zimage_vars(&content, &self.options.pipeline_vars);
2170            let zimage = crate::zimage::parse_zimagefile(&content)?;
2171            return self.handle_zimage(&zimage).await;
2172        }
2173
2174        // (c) Auto-detect ZImagefile in context directory. Accepts both a
2175        // literal `ZImagefile` and any `ZImagefile.<suffix>` (the convention
2176        // ZLayer itself uses: `ZImagefile.zlayer-node`,
2177        // `ZImagefile.zlayer-manager`, etc.). Ambiguity (multiple
2178        // `ZImagefile.<suffix>` entries with no literal tiebreaker) is a
2179        // hard error — the user must pass `-z <path>` to disambiguate.
2180        let auto_zimage_path =
2181            find_context_zimagefile(&self.context).map_err(|e| BuildError::ContextRead {
2182                path: self.context.clone(),
2183                source: e,
2184            })?;
2185        if let Some(auto_zimage_path) = auto_zimage_path {
2186            debug!(
2187                "Found ZImagefile in context: {}",
2188                auto_zimage_path.display()
2189            );
2190            let content = fs::read_to_string(&auto_zimage_path).await.map_err(|e| {
2191                BuildError::ContextRead {
2192                    path: auto_zimage_path,
2193                    source: e,
2194                }
2195            })?;
2196            let content = expand_zimage_vars(&content, &self.options.pipeline_vars);
2197            let zimage = crate::zimage::parse_zimagefile(&content)?;
2198            return self.handle_zimage(&zimage).await;
2199        }
2200
2201        // (d) Fall back to Dockerfile.
2202        let dockerfile_path = self
2203            .options
2204            .dockerfile
2205            .clone()
2206            .unwrap_or_else(|| self.context.join("Dockerfile"));
2207
2208        debug!("Reading Dockerfile: {}", dockerfile_path.display());
2209
2210        let content =
2211            fs::read_to_string(&dockerfile_path)
2212                .await
2213                .map_err(|e| BuildError::ContextRead {
2214                    path: dockerfile_path,
2215                    source: e,
2216                })?;
2217
2218        Ok(BuildOutput::Dockerfile(Dockerfile::parse(&content)?))
2219    }
2220
2221    /// Convert a parsed [`ZImage`] into a [`BuildOutput`].
2222    ///
2223    /// Handles all four `ZImage` modes:
2224    /// - **Runtime** mode: delegates to the template system -> [`BuildOutput::Dockerfile`]
2225    /// - **Single-stage / Multi-stage**: converts via [`zimage_to_dockerfile`] -> [`BuildOutput::Dockerfile`]
2226    /// - **WASM** mode: builds a WASM component -> [`BuildOutput::WasmArtifact`]
2227    ///
2228    /// Any `build:` directives are resolved first by spawning nested builds.
2229    async fn handle_zimage(&self, zimage: &crate::zimage::ZImage) -> Result<BuildOutput> {
2230        // Runtime mode: delegate to template system.
2231        if let Some(ref runtime_name) = zimage.runtime {
2232            let rt = Runtime::from_name(runtime_name).ok_or_else(|| {
2233                BuildError::zimagefile_validation(format!(
2234                    "unknown runtime '{runtime_name}' in ZImagefile"
2235                ))
2236            })?;
2237            let content = get_template(rt);
2238            return Ok(BuildOutput::Dockerfile(Dockerfile::parse(content)?));
2239        }
2240
2241        // WASM mode: build a WASM component.
2242        if zimage.wasm.is_some() {
2243            return self.handle_wasm_build(zimage).await;
2244        }
2245
2246        // Resolve any `build:` directives to concrete base image tags.
2247        let resolved = self.resolve_build_directives(zimage).await?;
2248
2249        // Single-stage or multi-stage: convert to Dockerfile IR directly.
2250        Ok(BuildOutput::Dockerfile(
2251            crate::zimage::zimage_to_dockerfile(&resolved)?,
2252        ))
2253    }
2254
2255    /// Build a WASM component from the `ZImagefile` wasm configuration.
2256    ///
2257    /// Converts [`ZWasmConfig`](crate::zimage::ZWasmConfig) into a
2258    /// [`WasmBuildConfig`](crate::wasm_builder::WasmBuildConfig) and invokes
2259    /// the WASM builder pipeline.
2260    #[allow(clippy::too_many_lines)]
2261    async fn handle_wasm_build(&self, zimage: &crate::zimage::ZImage) -> Result<BuildOutput> {
2262        use crate::wasm_builder::{build_wasm, WasiTarget, WasmBuildConfig, WasmLanguage};
2263        use zlayer_registry::wasm::WasiVersion;
2264        use zlayer_registry::{export_wasm_as_oci, WasmExportConfig};
2265
2266        // Caller guarantees `zimage.wasm` is `Some`.
2267        let wasm_config = zimage.wasm.as_ref().expect(
2268            "handle_wasm_build invoked without a wasm section in ZImage; caller must check",
2269        );
2270
2271        info!("ZImagefile specifies WASM mode, running WASM build");
2272
2273        // Convert target string to WasiTarget enum.
2274        let target = match wasm_config.target.as_str() {
2275            "preview1" => WasiTarget::Preview1,
2276            _ => WasiTarget::Preview2,
2277        };
2278
2279        // Resolve language: parse from string or leave as None for auto-detection.
2280        let language = wasm_config
2281            .language
2282            .as_deref()
2283            .and_then(WasmLanguage::from_name);
2284
2285        if let Some(ref lang_str) = wasm_config.language {
2286            if language.is_none() {
2287                return Err(BuildError::zimagefile_validation(format!(
2288                    "unknown WASM language '{lang_str}'. Supported: rust, go, python, \
2289                     typescript, assemblyscript, c, zig"
2290                )));
2291            }
2292        }
2293
2294        // Build the WasmBuildConfig.
2295        let mut config = WasmBuildConfig {
2296            language,
2297            target,
2298            optimize: wasm_config.optimize,
2299            opt_level: wasm_config
2300                .opt_level
2301                .clone()
2302                .unwrap_or_else(|| "Oz".to_string()),
2303            wit_path: wasm_config.wit.as_ref().map(PathBuf::from),
2304            output_path: wasm_config.output.as_ref().map(PathBuf::from),
2305            world: wasm_config.world.clone(),
2306            features: wasm_config.features.clone(),
2307            build_args: wasm_config.build_args.clone(),
2308            pre_build: Vec::new(),
2309            post_build: Vec::new(),
2310            adapter: wasm_config.adapter.as_ref().map(PathBuf::from),
2311        };
2312
2313        // Convert ZCommand pre/post build steps to Vec<Vec<String>>.
2314        for cmd in &wasm_config.pre_build {
2315            config.pre_build.push(zcommand_to_args(cmd));
2316        }
2317        for cmd in &wasm_config.post_build {
2318            config.post_build.push(zcommand_to_args(cmd));
2319        }
2320
2321        // Build the WASM component.
2322        let result = build_wasm(&self.context, config).await?;
2323
2324        let language_name = result.language.name().to_string();
2325        let wasm_path = result.wasm_path;
2326        let size = result.size;
2327
2328        info!(
2329            "WASM build complete: {} ({} bytes, optimized={})",
2330            wasm_path.display(),
2331            size,
2332            wasm_config.optimize
2333        );
2334
2335        // `wasm.oci: false` opts out of OCI artifact packaging and push —
2336        // the compilation pipeline above still runs (with caching, wasm-opt,
2337        // and the preview1 -> preview2 adapter), we simply skip the layout
2338        // write and leave `oci_path`/`manifest_digest`/`artifact_type` as
2339        // `None`. The push branch in `build()` keys off `oci_path.is_some()`
2340        // so skipping it here transparently disables push for this build.
2341        if !wasm_config.oci {
2342            info!(
2343                "WASM OCI export skipped (wasm.oci = false); raw .wasm at {}",
2344                wasm_path.display()
2345            );
2346            return Ok(BuildOutput::WasmArtifact {
2347                wasm_path,
2348                oci_path: None,
2349                manifest_digest: None,
2350                artifact_type: None,
2351                language: language_name,
2352                optimized: wasm_config.optimize,
2353                size,
2354            });
2355        }
2356
2357        // Derive a module name for OCI annotations. Prefer the first tag's
2358        // repository component (`repo` from `repo:version` or `host/repo`),
2359        // falling back to the wasm file stem, then "wasm-module".
2360        let module_name = self
2361            .options
2362            .tags
2363            .first()
2364            .map(|t| module_name_from_tag(t))
2365            .or_else(|| {
2366                wasm_path
2367                    .file_stem()
2368                    .and_then(|s| s.to_str())
2369                    .map(str::to_string)
2370            })
2371            .unwrap_or_else(|| "wasm-module".to_string());
2372
2373        // Map the selected WASI target to a WasiVersion so the export uses
2374        // the correct artifact_type without re-analyzing the binary.
2375        let wasi_version = match target {
2376            WasiTarget::Preview1 => Some(WasiVersion::Preview1),
2377            WasiTarget::Preview2 => Some(WasiVersion::Preview2),
2378        };
2379
2380        // Carry ZImage labels across as OCI manifest annotations, matching
2381        // the behaviour of container image builds that emit LABEL -> annotations.
2382        let annotations: HashMap<String, String> = zimage.labels.clone();
2383
2384        let export_config = WasmExportConfig {
2385            wasm_path: wasm_path.clone(),
2386            module_name: module_name.clone(),
2387            wasi_version,
2388            annotations,
2389        };
2390
2391        let export =
2392            export_wasm_as_oci(&export_config)
2393                .await
2394                .map_err(|e| BuildError::RegistryError {
2395                    message: format!("failed to export WASM as OCI artifact: {e}"),
2396                })?;
2397
2398        // Write the OCI image layout to disk next to the WASM file. The
2399        // layout directory name is `<module>-oci`, mirroring the CLI
2400        // `zlayer wasm export` layout in bin/zlayer/src/commands/wasm.rs.
2401        let layout_parent = wasm_path
2402            .parent()
2403            .map_or_else(|| self.context.clone(), Path::to_path_buf);
2404        let oci_dir = layout_parent.join(format!("{module_name}-oci"));
2405        write_wasm_oci_layout(&oci_dir, &export, &module_name).await?;
2406
2407        info!(
2408            manifest_digest = %export.manifest_digest,
2409            artifact_type = %export.artifact_type,
2410            oci_path = %oci_dir.display(),
2411            "WASM OCI artifact written"
2412        );
2413
2414        Ok(BuildOutput::WasmArtifact {
2415            wasm_path,
2416            oci_path: Some(oci_dir),
2417            manifest_digest: Some(export.manifest_digest),
2418            artifact_type: Some(export.artifact_type),
2419            language: language_name,
2420            optimized: wasm_config.optimize,
2421            size,
2422        })
2423    }
2424
2425    /// Resolve `build:` directives in a `ZImage` by running nested builds.
2426    ///
2427    /// For each `build:` directive (top-level or per-stage), this method:
2428    /// 1. Determines the build context directory
2429    /// 2. Auto-detects the build file (`ZImagefile` > Dockerfile) unless specified
2430    /// 3. Spawns a nested `ImageBuilder` to build the context
2431    /// 4. Tags the result and replaces `build` with `base`
2432    async fn resolve_build_directives(
2433        &self,
2434        zimage: &crate::zimage::ZImage,
2435    ) -> Result<crate::zimage::ZImage> {
2436        let mut resolved = zimage.clone();
2437
2438        // Resolve top-level `build:` directive.
2439        if let Some(ref build_ctx) = resolved.build {
2440            let tag = self.run_nested_build(build_ctx, "toplevel").await?;
2441            resolved.base = Some(tag);
2442            resolved.build = None;
2443        }
2444
2445        // Resolve per-stage `build:` directives.
2446        if let Some(ref mut stages) = resolved.stages {
2447            for (name, stage) in stages.iter_mut() {
2448                if let Some(ref build_ctx) = stage.build {
2449                    let tag = self.run_nested_build(build_ctx, name).await?;
2450                    stage.base = Some(tag);
2451                    stage.build = None;
2452                }
2453            }
2454        }
2455
2456        Ok(resolved)
2457    }
2458
2459    /// Run a nested build from a `build:` directive and return the resulting image tag.
2460    fn run_nested_build<'a>(
2461        &'a self,
2462        build_ctx: &'a crate::zimage::types::ZBuildContext,
2463        stage_name: &'a str,
2464    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
2465        Box::pin(self.run_nested_build_inner(build_ctx, stage_name))
2466    }
2467
2468    async fn run_nested_build_inner(
2469        &self,
2470        build_ctx: &crate::zimage::types::ZBuildContext,
2471        stage_name: &str,
2472    ) -> Result<String> {
2473        let context_dir = build_ctx.context_dir(&self.context);
2474
2475        if !context_dir.exists() {
2476            return Err(BuildError::ContextRead {
2477                path: context_dir,
2478                source: std::io::Error::new(
2479                    std::io::ErrorKind::NotFound,
2480                    format!(
2481                        "build context directory not found for build directive in '{stage_name}'"
2482                    ),
2483                ),
2484            });
2485        }
2486
2487        info!(
2488            "Building nested image for '{}' from context: {}",
2489            stage_name,
2490            context_dir.display()
2491        );
2492
2493        // Create a tag for the nested build result.
2494        let tag = format!(
2495            "zlayer-build-dep-{}:{}",
2496            stage_name,
2497            chrono_lite_timestamp()
2498        );
2499
2500        // Create nested builder. Inherit the parent's target_os (if any) so
2501        // a Windows top-level build doesn't silently spawn a Linux nested
2502        // build for its `build:` dependency.
2503        let mut nested = ImageBuilder::new_with_os(&context_dir, self.target_os).await?;
2504        nested = nested.tag(&tag);
2505
2506        // Apply explicit build file if specified.
2507        if let Some(file) = build_ctx.file() {
2508            let file_path = context_dir.join(file);
2509            if std::path::Path::new(file).extension().is_some_and(|ext| {
2510                ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")
2511            }) || file.starts_with("ZImagefile")
2512            {
2513                nested = nested.zimagefile(file_path);
2514            } else {
2515                nested = nested.dockerfile(file_path);
2516            }
2517        }
2518
2519        // Apply build args.
2520        for (key, value) in build_ctx.args() {
2521            nested = nested.build_arg(&key, &value);
2522        }
2523
2524        // Propagate default registry if set.
2525        if let Some(ref reg) = self.options.default_registry {
2526            nested = nested.default_registry(reg.clone());
2527        }
2528
2529        // Run the nested build.
2530        let result = nested.build().await?;
2531        info!(
2532            "Nested build for '{}' completed: {}",
2533            stage_name, result.image_id
2534        );
2535
2536        Ok(tag)
2537    }
2538
2539    /// Push the WASM OCI artifact produced by `handle_wasm_build` to every
2540    /// user-supplied registry tag.
2541    ///
2542    /// Mirrors the container push flow in [`BuildahBackend::build_image`]:
2543    /// when `options.push` is true, each tag in `options.tags` is pushed.
2544    /// Tags that look like bare image names (no registry host, e.g.
2545    /// `myapp:wasm`) are skipped with an info log, matching how bare tags
2546    /// are treated elsewhere — a registryless tag has nowhere to be pushed.
2547    ///
2548    /// Re-runs [`export_wasm_as_oci`] on the produced `wasm_path` to obtain
2549    /// the [`WasmExportResult`] blobs required by [`ImagePuller::push_wasm`].
2550    /// The export is deterministic (same WASM binary produces the same
2551    /// blobs and digests), so the digests match the layout on disk at
2552    /// `oci_dir` that A1.2 wrote.
2553    ///
2554    /// [`BuildahBackend::build_image`]: crate::backend::buildah::BuildahBackend
2555    /// [`export_wasm_as_oci`]: zlayer_registry::export_wasm_as_oci
2556    /// [`WasmExportResult`]: zlayer_registry::WasmExportResult
2557    /// [`ImagePuller::push_wasm`]: zlayer_registry::ImagePuller::push_wasm
2558    #[cfg(feature = "local-registry")]
2559    async fn push_wasm_oci(&self, wasm_path: &Path, oci_dir: &Path) -> Result<()> {
2560        use zlayer_registry::wasm::WasiVersion;
2561        use zlayer_registry::{export_wasm_as_oci, BlobCache, ImagePuller, WasmExportConfig};
2562
2563        // Derive the module name the same way `handle_wasm_build` did so the
2564        // re-exported artifact carries identical OCI annotations.
2565        let module_name = self
2566            .options
2567            .tags
2568            .first()
2569            .map(|t| module_name_from_tag(t))
2570            .or_else(|| {
2571                wasm_path
2572                    .file_stem()
2573                    .and_then(|s| s.to_str())
2574                    .map(str::to_string)
2575            })
2576            .unwrap_or_else(|| "wasm-module".to_string());
2577
2578        // Reconstruct the export result from the on-disk WASM binary. The
2579        // `wasi_version` is left `None` so it is re-detected from the binary
2580        // (matches whatever A1.2 wrote unless the user mutated the file).
2581        let export_config = WasmExportConfig {
2582            wasm_path: wasm_path.to_path_buf(),
2583            module_name,
2584            wasi_version: None::<WasiVersion>,
2585            annotations: HashMap::new(),
2586        };
2587        let export =
2588            export_wasm_as_oci(&export_config)
2589                .await
2590                .map_err(|e| BuildError::RegistryError {
2591                    message: format!(
2592                        "failed to re-export WASM for push from {}: {e}",
2593                        wasm_path.display()
2594                    ),
2595                })?;
2596
2597        // Build the puller once; reuse for every tag.
2598        let cache = BlobCache::new().map_err(|e| BuildError::RegistryError {
2599            message: format!("failed to create blob cache for WASM push: {e}"),
2600        })?;
2601        let puller = ImagePuller::new(cache);
2602
2603        for tag in &self.options.tags {
2604            if !tag_has_registry_host(tag) {
2605                info!(
2606                    "Skipping WASM push for bare tag '{}' (no registry host); \
2607                     OCI layout still available at {}",
2608                    tag,
2609                    oci_dir.display()
2610                );
2611                continue;
2612            }
2613
2614            let oci_auth = Self::resolve_wasm_push_auth(self.options.registry_auth.as_ref());
2615
2616            info!("Pushing WASM artifact: {}", tag);
2617            let push_result = puller
2618                .push_wasm(tag, &export, &oci_auth)
2619                .await
2620                .map_err(|e| BuildError::RegistryError {
2621                    message: format!("failed to push WASM artifact '{tag}': {e}"),
2622                })?;
2623            info!(
2624                "Pushed WASM artifact: {} (manifest digest: {})",
2625                tag, push_result.manifest_digest
2626            );
2627        }
2628
2629        Ok(())
2630    }
2631
2632    /// Resolve registry auth for a WASM push.
2633    ///
2634    /// Uses the explicitly provided credentials when set; otherwise falls
2635    /// back to anonymous. Mirrors the minimal behaviour of the buildah push
2636    /// path (`--creds user:pass` when provided, otherwise let the registry
2637    /// decide).
2638    #[cfg(feature = "local-registry")]
2639    fn resolve_wasm_push_auth(auth: Option<&RegistryAuth>) -> zlayer_registry::RegistryAuth {
2640        match auth {
2641            Some(a) => zlayer_registry::RegistryAuth::Basic(a.username.clone(), a.password.clone()),
2642            None => zlayer_registry::RegistryAuth::Anonymous,
2643        }
2644    }
2645
2646    /// Send an event to the TUI (if configured)
2647    fn send_event(&self, event: BuildEvent) {
2648        if let Some(tx) = &self.event_tx {
2649            // Ignore send errors - the receiver may have been dropped
2650            let _ = tx.send(event);
2651        }
2652    }
2653}
2654
2655// Helper function to generate a timestamp-based name
2656fn chrono_lite_timestamp() -> String {
2657    use std::time::{SystemTime, UNIX_EPOCH};
2658    let duration = SystemTime::now()
2659        .duration_since(UNIX_EPOCH)
2660        .unwrap_or_default();
2661    format!("{}", duration.as_secs())
2662}
2663
2664/// Convert a [`ZCommand`](crate::zimage::ZCommand) into a vector of string arguments
2665/// suitable for passing to [`WasmBuildConfig`](crate::wasm_builder::WasmBuildConfig)
2666/// pre/post build command lists.
2667fn zcommand_to_args(cmd: &crate::zimage::ZCommand) -> Vec<String> {
2668    match cmd {
2669        crate::zimage::ZCommand::Shell(s) => {
2670            vec!["/bin/sh".to_string(), "-c".to_string(), s.clone()]
2671        }
2672        crate::zimage::ZCommand::Exec(args) => args.clone(),
2673    }
2674}
2675
2676/// Extract a short "module name" suitable for OCI annotations from an image
2677/// tag. Strips any registry host, leading path segments, and tag/digest.
2678///
2679/// Examples:
2680/// - `myapp:latest` -> `myapp`
2681/// - `ghcr.io/org/myapp:v1.2.3` -> `myapp`
2682/// - `myapp@sha256:...` -> `myapp`
2683fn module_name_from_tag(tag: &str) -> String {
2684    let last_segment = tag.rsplit('/').next().unwrap_or(tag);
2685    let without_tag = last_segment.split(':').next().unwrap_or(last_segment);
2686    let without_digest = without_tag.split('@').next().unwrap_or(without_tag);
2687    without_digest.to_string()
2688}
2689
2690/// Heuristic: does `tag` include an explicit registry host?
2691///
2692/// Used to decide which tags are push-eligible. A tag is treated as
2693/// registry-qualified when it has at least one `/` and the first path
2694/// component looks like a host — it contains a `.` (FQDN like `ghcr.io`,
2695/// `registry.example.com`), a `:` (host:port like `localhost:5000`), or
2696/// equals the literal `localhost`. Bare names like `myapp:wasm` and
2697/// Docker-Hub-style `org/app:v1` are skipped because there is no explicit
2698/// registry to push to.
2699#[cfg(feature = "local-registry")]
2700fn tag_has_registry_host(tag: &str) -> bool {
2701    // No `/` means the whole string is `name[:tag]` with no host component.
2702    if !tag.contains('/') {
2703        return false;
2704    }
2705    let Some(first) = tag.split('/').next() else {
2706        return false;
2707    };
2708    first.contains('.') || first.contains(':') || first == "localhost"
2709}
2710
2711/// Write an OCI image layout directory (`oci-layout`, `index.json`,
2712/// `blobs/sha256/...`) for a WASM artifact on disk. This mirrors the layout
2713/// emitted by the `zlayer wasm export` CLI command so the directory can be
2714/// consumed by tools that expect a standard OCI layout.
2715async fn write_wasm_oci_layout(
2716    oci_dir: &Path,
2717    export: &zlayer_registry::WasmExportResult,
2718    ref_name: &str,
2719) -> Result<()> {
2720    let map_io = |path: PathBuf| {
2721        move |e: std::io::Error| BuildError::ContextRead {
2722            path: path.clone(),
2723            source: e,
2724        }
2725    };
2726
2727    fs::create_dir_all(oci_dir)
2728        .await
2729        .map_err(map_io(oci_dir.to_path_buf()))?;
2730
2731    // `oci-layout` marker file.
2732    let layout_marker = oci_dir.join("oci-layout");
2733    let oci_layout = serde_json::json!({ "imageLayoutVersion": "1.0.0" });
2734    fs::write(
2735        &layout_marker,
2736        serde_json::to_vec_pretty(&oci_layout).map_err(|e| BuildError::RegistryError {
2737            message: format!("failed to serialize oci-layout marker: {e}"),
2738        })?,
2739    )
2740    .await
2741    .map_err(map_io(layout_marker.clone()))?;
2742
2743    // `blobs/sha256/` directory.
2744    let blobs_dir = oci_dir.join("blobs").join("sha256");
2745    fs::create_dir_all(&blobs_dir)
2746        .await
2747        .map_err(map_io(blobs_dir.clone()))?;
2748
2749    // Write config, wasm-layer, and manifest blobs under their digests.
2750    let write_blob = |digest: &str, data: &[u8]| {
2751        let hash = digest.strip_prefix("sha256:").unwrap_or(digest).to_string();
2752        let path = blobs_dir.join(hash);
2753        let data = data.to_vec();
2754        async move {
2755            fs::write(&path, &data)
2756                .await
2757                .map_err(map_io(path.clone()))?;
2758            Ok::<(), BuildError>(())
2759        }
2760    };
2761
2762    write_blob(&export.config_digest, &export.config_blob).await?;
2763    write_blob(&export.wasm_layer_digest, &export.wasm_binary).await?;
2764    write_blob(&export.manifest_digest, &export.manifest_json).await?;
2765
2766    // Write `index.json` pointing at the manifest.
2767    let index = serde_json::json!({
2768        "schemaVersion": 2,
2769        "mediaType": "application/vnd.oci.image.index.v1+json",
2770        "manifests": [{
2771            "mediaType": "application/vnd.oci.image.manifest.v1+json",
2772            "digest": export.manifest_digest,
2773            "size": export.manifest_size,
2774            "artifactType": export.artifact_type,
2775            "annotations": {
2776                "org.opencontainers.image.ref.name": ref_name,
2777            }
2778        }]
2779    });
2780    let index_path = oci_dir.join("index.json");
2781    fs::write(
2782        &index_path,
2783        serde_json::to_vec_pretty(&index).map_err(|e| BuildError::RegistryError {
2784            message: format!("failed to serialize OCI index.json: {e}"),
2785        })?,
2786    )
2787    .await
2788    .map_err(map_io(index_path.clone()))?;
2789
2790    Ok(())
2791}
2792
2793#[cfg(test)]
2794mod tests {
2795    use super::*;
2796
2797    #[test]
2798    fn expand_zimage_vars_substitutes_known_and_keeps_unknown() {
2799        let mut vars = std::collections::HashMap::new();
2800        vars.insert("LTSC".to_string(), "ltsc2025".to_string());
2801        vars.insert(
2802            "REGISTRY".to_string(),
2803            "ghcr.io/blackleafdigital/zlayer".to_string(),
2804        );
2805        let content = "base: ${REGISTRY}/base:windows-${LTSC}\nrun: echo ${UNKNOWN}\n";
2806        let out = expand_zimage_vars(content, &vars);
2807        assert_eq!(
2808            out,
2809            "base: ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2025\nrun: echo ${UNKNOWN}\n"
2810        );
2811    }
2812
2813    #[test]
2814    fn expand_zimage_vars_empty_is_noop() {
2815        let vars = std::collections::HashMap::new();
2816        let content = "base: mcr.microsoft.com/windows/servercore:${LTSC}\n";
2817        assert_eq!(expand_zimage_vars(content, &vars), content);
2818    }
2819
2820    #[test]
2821    fn find_context_zimagefile_prefers_literal() {
2822        let dir = tempfile::tempdir().unwrap();
2823        // Both a literal `ZImagefile` AND a `.suffix` variant exist — the
2824        // literal wins outright per the documented resolution order.
2825        std::fs::write(dir.path().join("ZImagefile"), "base: alpine\n").unwrap();
2826        std::fs::write(dir.path().join("ZImagefile.prod"), "base: alpine\n").unwrap();
2827        let found = find_context_zimagefile(dir.path()).unwrap();
2828        assert_eq!(found, Some(dir.path().join("ZImagefile")));
2829    }
2830
2831    #[test]
2832    fn find_context_zimagefile_picks_unique_suffix() {
2833        let dir = tempfile::tempdir().unwrap();
2834        // Only one `ZImagefile.<suffix>` and no literal — auto-detect
2835        // returns it. This is the common multi-image-repo convention
2836        // (e.g. `ZImagefile.zataserver`).
2837        std::fs::write(dir.path().join("ZImagefile.zataserver"), "base: rust\n").unwrap();
2838        let found = find_context_zimagefile(dir.path()).unwrap();
2839        assert_eq!(found, Some(dir.path().join("ZImagefile.zataserver")));
2840    }
2841
2842    #[test]
2843    fn find_context_zimagefile_none_when_only_dockerfile() {
2844        let dir = tempfile::tempdir().unwrap();
2845        std::fs::write(dir.path().join("Dockerfile"), "FROM alpine\n").unwrap();
2846        let found = find_context_zimagefile(dir.path()).unwrap();
2847        assert_eq!(found, None);
2848    }
2849
2850    #[test]
2851    fn find_context_zimagefile_ambiguous_errors() {
2852        let dir = tempfile::tempdir().unwrap();
2853        // Two `.suffix` variants and NO literal `ZImagefile` to break the
2854        // tie — silently picking either one is a footgun, so this MUST
2855        // surface as an error pointing the user at `-z <path>`.
2856        std::fs::write(dir.path().join("ZImagefile.prod"), "base: alpine\n").unwrap();
2857        std::fs::write(dir.path().join("ZImagefile.dev"), "base: alpine\n").unwrap();
2858        let err = find_context_zimagefile(dir.path()).unwrap_err();
2859        let msg = err.to_string();
2860        assert!(
2861            msg.contains("multiple ZImagefile candidates"),
2862            "error must explain ambiguity, got: {msg}"
2863        );
2864        assert!(msg.contains("ZImagefile.dev"));
2865        assert!(msg.contains("ZImagefile.prod"));
2866        assert!(msg.contains("-z"));
2867    }
2868
2869    #[test]
2870    fn find_context_zimagefile_ignores_empty_suffix() {
2871        // A file named literally `ZImagefile.` (with a trailing dot and no
2872        // suffix) is a weird edge case but it shouldn't be picked up as a
2873        // candidate — the helper requires a non-empty suffix.
2874        let dir = tempfile::tempdir().unwrap();
2875        std::fs::write(dir.path().join("ZImagefile."), "junk\n").unwrap();
2876        let found = find_context_zimagefile(dir.path()).unwrap();
2877        assert_eq!(found, None);
2878    }
2879
2880    #[test]
2881    fn test_registry_auth_new() {
2882        let auth = RegistryAuth::new("user", "pass");
2883        assert_eq!(auth.username, "user");
2884        assert_eq!(auth.password, "pass");
2885    }
2886
2887    #[test]
2888    fn test_build_options_default() {
2889        let opts = BuildOptions::default();
2890        assert!(opts.dockerfile.is_none());
2891        assert!(opts.zimagefile.is_none());
2892        assert!(opts.runtime.is_none());
2893        assert!(opts.build_args.is_empty());
2894        assert!(opts.target.is_none());
2895        assert!(opts.tags.is_empty());
2896        assert!(!opts.no_cache);
2897        assert!(!opts.push);
2898        assert!(!opts.squash);
2899        // New cache-related fields
2900        assert!(opts.layers); // Default is true
2901        assert!(opts.cache_from.is_none());
2902        assert!(opts.cache_to.is_none());
2903        assert!(opts.cache_ttl.is_none());
2904        // Cache backend config (only with cache feature)
2905        #[cfg(feature = "cache")]
2906        assert!(opts.cache_backend_config.is_none());
2907    }
2908
2909    fn create_test_builder() -> ImageBuilder {
2910        // Create a minimal builder for testing (without async initialization)
2911        ImageBuilder {
2912            context: PathBuf::from("/tmp/test"),
2913            options: BuildOptions::default(),
2914            executor: BuildahExecutor::with_path("/usr/bin/buildah"),
2915            event_tx: None,
2916            target_os: None,
2917            backend: None,
2918            #[cfg(feature = "cache")]
2919            cache_backend: None,
2920            #[cfg(feature = "local-registry")]
2921            local_registry: None,
2922        }
2923    }
2924
2925    // Builder method chaining tests
2926    #[test]
2927    fn test_builder_chaining() {
2928        let mut builder = create_test_builder();
2929
2930        builder = builder
2931            .dockerfile("./Dockerfile.test")
2932            .runtime(Runtime::Node20)
2933            .build_arg("VERSION", "1.0")
2934            .target("builder")
2935            .tag("myapp:latest")
2936            .tag("myapp:v1")
2937            .no_cache()
2938            .squash()
2939            .format("oci");
2940
2941        assert_eq!(
2942            builder.options.dockerfile,
2943            Some(PathBuf::from("./Dockerfile.test"))
2944        );
2945        assert_eq!(builder.options.runtime, Some(Runtime::Node20));
2946        assert_eq!(
2947            builder.options.build_args.get("VERSION"),
2948            Some(&"1.0".to_string())
2949        );
2950        assert_eq!(builder.options.target, Some("builder".to_string()));
2951        assert_eq!(builder.options.tags.len(), 2);
2952        assert!(builder.options.no_cache);
2953        assert!(builder.options.squash);
2954        assert_eq!(builder.options.format, Some("oci".to_string()));
2955    }
2956
2957    #[test]
2958    fn test_builder_push_with_auth() {
2959        let mut builder = create_test_builder();
2960        builder = builder.push(RegistryAuth::new("user", "pass"));
2961
2962        assert!(builder.options.push);
2963        assert!(builder.options.registry_auth.is_some());
2964        let auth = builder.options.registry_auth.unwrap();
2965        assert_eq!(auth.username, "user");
2966        assert_eq!(auth.password, "pass");
2967    }
2968
2969    #[test]
2970    fn test_builder_push_without_auth() {
2971        let mut builder = create_test_builder();
2972        builder = builder.push_without_auth();
2973
2974        assert!(builder.options.push);
2975        assert!(builder.options.registry_auth.is_none());
2976    }
2977
2978    #[test]
2979    fn test_builder_layers() {
2980        let mut builder = create_test_builder();
2981        // Default is true
2982        assert!(builder.options.layers);
2983
2984        // Disable layers
2985        builder = builder.layers(false);
2986        assert!(!builder.options.layers);
2987
2988        // Re-enable layers
2989        builder = builder.layers(true);
2990        assert!(builder.options.layers);
2991    }
2992
2993    #[test]
2994    fn test_builder_cache_from() {
2995        let mut builder = create_test_builder();
2996        assert!(builder.options.cache_from.is_none());
2997
2998        builder = builder.cache_from("registry.example.com/myapp:cache");
2999        assert_eq!(
3000            builder.options.cache_from,
3001            Some("registry.example.com/myapp:cache".to_string())
3002        );
3003    }
3004
3005    #[test]
3006    fn test_builder_cache_to() {
3007        let mut builder = create_test_builder();
3008        assert!(builder.options.cache_to.is_none());
3009
3010        builder = builder.cache_to("registry.example.com/myapp:cache");
3011        assert_eq!(
3012            builder.options.cache_to,
3013            Some("registry.example.com/myapp:cache".to_string())
3014        );
3015    }
3016
3017    #[test]
3018    fn test_builder_cache_ttl() {
3019        use std::time::Duration;
3020
3021        let mut builder = create_test_builder();
3022        assert!(builder.options.cache_ttl.is_none());
3023
3024        builder = builder.cache_ttl(Duration::from_secs(3600));
3025        assert_eq!(builder.options.cache_ttl, Some(Duration::from_secs(3600)));
3026    }
3027
3028    #[test]
3029    fn test_builder_cache_options_chaining() {
3030        use std::time::Duration;
3031
3032        let builder = create_test_builder()
3033            .layers(true)
3034            .cache_from("registry.example.com/cache:input")
3035            .cache_to("registry.example.com/cache:output")
3036            .cache_ttl(Duration::from_secs(7200))
3037            .no_cache();
3038
3039        assert!(builder.options.layers);
3040        assert_eq!(
3041            builder.options.cache_from,
3042            Some("registry.example.com/cache:input".to_string())
3043        );
3044        assert_eq!(
3045            builder.options.cache_to,
3046            Some("registry.example.com/cache:output".to_string())
3047        );
3048        assert_eq!(builder.options.cache_ttl, Some(Duration::from_secs(7200)));
3049        assert!(builder.options.no_cache);
3050    }
3051
3052    #[test]
3053    fn test_chrono_lite_timestamp() {
3054        let ts = chrono_lite_timestamp();
3055        // Should be a valid number
3056        let parsed: u64 = ts.parse().expect("Should be a valid u64");
3057        // Should be reasonably recent (after 2024)
3058        assert!(parsed > 1_700_000_000);
3059    }
3060}