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}