use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::Arc;
use tokio::fs;
#[cfg(feature = "local-registry")]
use tracing::warn;
use tracing::{debug, info, instrument};
use crate::backend::BuildBackend;
#[cfg(feature = "local-registry")]
use crate::buildah::BuildahCommand;
use crate::buildah::BuildahExecutor;
use crate::dockerfile::{Dockerfile, RunMount};
use crate::error::{BuildError, Result};
use crate::templates::{get_template, Runtime};
use crate::tui::BuildEvent;
#[cfg(feature = "cache")]
use zlayer_registry::cache::BlobCacheBackend;
#[cfg(feature = "local-registry")]
use zlayer_registry::LocalRegistry;
#[cfg(feature = "local-registry")]
use zlayer_registry::import_image;
#[derive(Debug)]
pub enum BuildOutput {
Dockerfile(Dockerfile),
WasmArtifact {
wasm_path: PathBuf,
oci_path: Option<PathBuf>,
manifest_digest: Option<String>,
artifact_type: Option<String>,
language: String,
optimized: bool,
size: u64,
},
}
#[cfg(feature = "cache")]
#[derive(Debug, Clone, Default)]
pub enum CacheBackendConfig {
#[default]
Memory,
#[cfg(feature = "cache-persistent")]
Persistent {
path: PathBuf,
},
#[cfg(feature = "cache-s3")]
S3 {
bucket: String,
region: Option<String>,
endpoint: Option<String>,
prefix: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct BuiltImage {
pub image_id: String,
pub tags: Vec<String>,
pub layer_count: usize,
pub size: u64,
pub build_time_ms: u64,
pub is_manifest: bool,
}
#[derive(Debug, Clone)]
pub struct RegistryAuth {
pub username: String,
pub password: String,
}
impl RegistryAuth {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: username.into(),
password: password.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PullBaseMode {
#[default]
Newer,
Always,
Never,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct BuildOptions {
pub dockerfile: Option<PathBuf>,
pub zimagefile: Option<PathBuf>,
pub runtime: Option<Runtime>,
pub build_args: HashMap<String, String>,
pub target: Option<String>,
pub tags: Vec<String>,
pub no_cache: bool,
pub push: bool,
pub registry_auth: Option<RegistryAuth>,
pub squash: bool,
pub format: Option<String>,
pub layers: bool,
pub cache_from: Option<String>,
pub cache_to: Option<String>,
pub cache_ttl: Option<std::time::Duration>,
#[cfg(feature = "cache")]
pub cache_backend_config: Option<CacheBackendConfig>,
pub default_registry: Option<String>,
pub default_cache_mounts: Vec<RunMount>,
pub retries: u32,
pub platform: Option<String>,
pub source_hash: Option<String>,
pub pull: PullBaseMode,
pub update_bottles: bool,
}
impl Default for BuildOptions {
fn default() -> Self {
Self {
dockerfile: None,
zimagefile: None,
runtime: None,
build_args: HashMap::new(),
target: None,
tags: Vec::new(),
no_cache: false,
push: false,
registry_auth: None,
squash: false,
format: None,
layers: true,
cache_from: None,
cache_to: None,
cache_ttl: None,
#[cfg(feature = "cache")]
cache_backend_config: None,
default_registry: None,
default_cache_mounts: Vec::new(),
retries: 0,
platform: None,
source_hash: None,
pull: PullBaseMode::default(),
update_bottles: false,
}
}
}
pub struct ImageBuilder {
context: PathBuf,
options: BuildOptions,
#[allow(dead_code)]
executor: BuildahExecutor,
event_tx: Option<mpsc::Sender<BuildEvent>>,
target_os: Option<crate::backend::ImageOs>,
backend: Option<Arc<dyn BuildBackend>>,
#[cfg(feature = "cache")]
cache_backend: Option<Arc<Box<dyn BlobCacheBackend>>>,
#[cfg(feature = "local-registry")]
local_registry: Option<LocalRegistry>,
}
impl ImageBuilder {
#[instrument(skip_all, fields(context = %context.as_ref().display()))]
pub async fn new(context: impl AsRef<Path>) -> Result<Self> {
Self::new_with_os(context, None).await
}
#[instrument(skip_all, fields(context = %context.as_ref().display(), target_os = ?target_os))]
pub async fn new_with_os(
context: impl AsRef<Path>,
target_os: Option<crate::backend::ImageOs>,
) -> Result<Self> {
let context = context.as_ref().to_path_buf();
if !context.exists() {
return Err(BuildError::ContextRead {
path: context,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"Build context directory not found",
),
});
}
let detection_os = target_os.unwrap_or(crate::backend::ImageOs::Linux);
let backend = crate::backend::detect_backend(detection_os).await.ok();
let executor = match BuildahExecutor::new_async().await {
Ok(exec) => exec,
#[cfg(target_os = "macos")]
Err(_) => {
info!("Buildah not found on macOS; backend will handle build dispatch");
BuildahExecutor::default()
}
#[cfg(not(target_os = "macos"))]
Err(e) => return Err(e),
};
debug!("Created ImageBuilder for context: {}", context.display());
Ok(Self {
context,
options: BuildOptions::default(),
executor,
event_tx: None,
target_os,
backend,
#[cfg(feature = "cache")]
cache_backend: None,
#[cfg(feature = "local-registry")]
local_registry: None,
})
}
pub async fn with_target_os(mut self, target_os: crate::backend::ImageOs) -> Result<Self> {
self.target_os = Some(target_os);
self.backend = Some(crate::backend::detect_backend(target_os).await?);
Ok(self)
}
pub fn with_executor(context: impl AsRef<Path>, executor: BuildahExecutor) -> Result<Self> {
let context = context.as_ref().to_path_buf();
if !context.exists() {
return Err(BuildError::ContextRead {
path: context,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"Build context directory not found",
),
});
}
let backend: Arc<dyn BuildBackend> = Arc::new(
crate::backend::BuildahBackend::with_executor(executor.clone()),
);
Ok(Self {
context,
options: BuildOptions::default(),
executor,
event_tx: None,
target_os: None,
backend: Some(backend),
#[cfg(feature = "cache")]
cache_backend: None,
#[cfg(feature = "local-registry")]
local_registry: None,
})
}
pub fn with_backend(context: impl AsRef<Path>, backend: Arc<dyn BuildBackend>) -> Result<Self> {
let context = context.as_ref().to_path_buf();
if !context.exists() {
return Err(BuildError::ContextRead {
path: context,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"Build context directory not found",
),
});
}
Ok(Self {
context,
options: BuildOptions::default(),
executor: BuildahExecutor::default(),
event_tx: None,
target_os: None,
backend: Some(backend),
#[cfg(feature = "cache")]
cache_backend: None,
#[cfg(feature = "local-registry")]
local_registry: None,
})
}
#[must_use]
pub fn dockerfile(mut self, path: impl AsRef<Path>) -> Self {
self.options.dockerfile = Some(path.as_ref().to_path_buf());
self
}
#[must_use]
pub fn zimagefile(mut self, path: impl AsRef<Path>) -> Self {
self.options.zimagefile = Some(path.as_ref().to_path_buf());
self
}
#[must_use]
pub fn runtime(mut self, runtime: Runtime) -> Self {
self.options.runtime = Some(runtime);
self
}
#[must_use]
pub fn build_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.options.build_args.insert(key.into(), value.into());
self
}
#[must_use]
pub fn build_args(mut self, args: HashMap<String, String>) -> Self {
self.options.build_args.extend(args);
self
}
#[must_use]
pub fn target(mut self, stage: impl Into<String>) -> Self {
self.options.target = Some(stage.into());
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.options.tags.push(tag.into());
self
}
#[must_use]
pub fn no_cache(mut self) -> Self {
self.options.no_cache = true;
self
}
#[must_use]
pub fn pull(mut self, mode: PullBaseMode) -> Self {
self.options.pull = mode;
self
}
#[must_use]
pub fn update_bottles(mut self, update_bottles: bool) -> Self {
self.options.update_bottles = update_bottles;
self
}
#[must_use]
pub fn layers(mut self, enable: bool) -> Self {
self.options.layers = enable;
self
}
#[must_use]
pub fn cache_from(mut self, registry: impl Into<String>) -> Self {
self.options.cache_from = Some(registry.into());
self
}
#[must_use]
pub fn cache_to(mut self, registry: impl Into<String>) -> Self {
self.options.cache_to = Some(registry.into());
self
}
#[must_use]
pub fn cache_ttl(mut self, ttl: std::time::Duration) -> Self {
self.options.cache_ttl = Some(ttl);
self
}
#[must_use]
pub fn push(mut self, auth: RegistryAuth) -> Self {
self.options.push = true;
self.options.registry_auth = Some(auth);
self
}
#[must_use]
pub fn push_without_auth(mut self) -> Self {
self.options.push = true;
self.options.registry_auth = None;
self
}
#[must_use]
pub fn default_registry(mut self, registry: impl Into<String>) -> Self {
self.options.default_registry = Some(registry.into());
self
}
#[cfg(feature = "local-registry")]
#[must_use]
pub fn with_local_registry(mut self, registry: LocalRegistry) -> Self {
self.local_registry = Some(registry);
self
}
#[must_use]
pub fn squash(mut self) -> Self {
self.options.squash = true;
self
}
#[must_use]
pub fn format(mut self, format: impl Into<String>) -> Self {
self.options.format = Some(format.into());
self
}
#[must_use]
pub fn default_cache_mounts(mut self, mounts: Vec<RunMount>) -> Self {
self.options.default_cache_mounts = mounts;
self
}
#[must_use]
pub fn retries(mut self, retries: u32) -> Self {
self.options.retries = retries;
self
}
#[must_use]
pub fn platform(mut self, platform: impl Into<String>) -> Self {
self.options.platform = Some(platform.into());
self
}
#[must_use]
pub fn source_hash(mut self, hash: impl Into<String>) -> Self {
self.options.source_hash = Some(hash.into());
self
}
#[must_use]
pub fn with_events(mut self, tx: mpsc::Sender<BuildEvent>) -> Self {
self.event_tx = Some(tx);
self
}
#[cfg(feature = "cache-persistent")]
#[must_use]
pub fn with_cache_dir(mut self, path: impl AsRef<Path>) -> Self {
self.options.cache_backend_config = Some(CacheBackendConfig::Persistent {
path: path.as_ref().to_path_buf(),
});
debug!(
"Configured persistent cache at: {}",
path.as_ref().display()
);
self
}
#[cfg(feature = "cache")]
#[must_use]
pub fn with_memory_cache(mut self) -> Self {
self.options.cache_backend_config = Some(CacheBackendConfig::Memory);
debug!("Configured in-memory cache");
self
}
#[cfg(feature = "cache-s3")]
#[must_use]
pub fn with_s3_cache(mut self, bucket: impl Into<String>, region: Option<String>) -> Self {
self.options.cache_backend_config = Some(CacheBackendConfig::S3 {
bucket: bucket.into(),
region,
endpoint: None,
prefix: None,
});
debug!("Configured S3 cache");
self
}
#[cfg(feature = "cache-s3")]
#[must_use]
pub fn with_s3_cache_endpoint(
mut self,
bucket: impl Into<String>,
endpoint: impl Into<String>,
region: Option<String>,
) -> Self {
self.options.cache_backend_config = Some(CacheBackendConfig::S3 {
bucket: bucket.into(),
region,
endpoint: Some(endpoint.into()),
prefix: None,
});
debug!("Configured S3 cache with custom endpoint");
self
}
#[cfg(feature = "cache")]
#[must_use]
pub fn with_cache_config(mut self, config: CacheBackendConfig) -> Self {
self.options.cache_backend_config = Some(config);
debug!("Configured custom cache backend");
self
}
#[cfg(feature = "cache")]
#[must_use]
pub fn with_cache_backend(mut self, backend: Arc<Box<dyn BlobCacheBackend>>) -> Self {
self.cache_backend = Some(backend);
debug!("Configured pre-initialized cache backend");
self
}
#[instrument(skip(self), fields(context = %self.context.display()))]
#[allow(clippy::too_many_lines)]
pub async fn build(mut self) -> Result<BuiltImage> {
let start_time = std::time::Instant::now();
info!("Starting build in context: {}", self.context.display());
self.resolve_target_os_and_backend().await?;
let build_output = self.get_build_output().await?;
if let BuildOutput::WasmArtifact {
wasm_path,
#[cfg_attr(not(feature = "local-registry"), allow(unused_variables))]
oci_path,
manifest_digest,
artifact_type: _,
language,
optimized,
size,
} = build_output
{
#[allow(clippy::cast_possible_truncation)]
let build_time_ms = start_time.elapsed().as_millis() as u64;
let image_id = if let Some(tag) = self.options.tags.first() {
tag.clone()
} else if let Some(digest) = manifest_digest.as_ref() {
format!("wasm:{digest}")
} else {
format!("wasm-path:{}", wasm_path.display())
};
#[cfg(feature = "local-registry")]
if oci_path.is_some() && self.options.push && !self.options.tags.is_empty() {
let oci_dir = oci_path.as_ref().expect("checked oci_path.is_some() above");
self.push_wasm_oci(&wasm_path, oci_dir).await?;
}
self.send_event(BuildEvent::BuildComplete {
image_id: image_id.clone(),
});
info!(
"WASM build completed in {}ms: {} ({}, {} bytes, optimized={}, image_id={})",
build_time_ms,
wasm_path.display(),
language,
size,
optimized,
image_id,
);
return Ok(BuiltImage {
image_id,
tags: self.options.tags.clone(),
layer_count: 1,
size,
build_time_ms,
is_manifest: false,
});
}
let BuildOutput::Dockerfile(dockerfile) = build_output else {
unreachable!("WasmArtifact case handled above");
};
debug!("Parsed Dockerfile with {} stages", dockerfile.stages.len());
if let Err(err) = crate::windows::deps::validate_dockerfile(&dockerfile) {
return Err(BuildError::InvalidInstruction {
instruction: "RUN".to_string(),
reason: err.to_string(),
});
}
let backend = self
.backend
.as_ref()
.ok_or_else(|| BuildError::BuildahNotFound {
message: "No build backend configured".into(),
})?;
info!("Delegating build to {} backend", backend.name());
let built = backend
.build_image(
&self.context,
&dockerfile,
&self.options,
self.event_tx.clone(),
)
.await?;
#[cfg(feature = "local-registry")]
if let Some(ref registry) = self.local_registry {
if !built.tags.is_empty() {
let tmp_path = std::env::temp_dir().join(format!(
"zlayer-build-{}-{}.tar",
std::process::id(),
start_time.elapsed().as_nanos()
));
let export_tag = &built.tags[0];
let dest = format!("oci-archive:{}", tmp_path.display());
let push_cmd = BuildahCommand::push_to(export_tag, &dest);
self.executor
.execute_checked(&push_cmd)
.await
.map_err(|e| BuildError::RegistryError {
message: format!(
"failed to export image to OCI archive for local registry \
import at {}: {e}",
registry.root().display()
),
})?;
let blob_cache: Option<&dyn zlayer_registry::cache::BlobCacheBackend> =
self.cache_backend.as_ref().map(|arc| arc.as_ref().as_ref());
let import_result = async {
for tag in &built.tags {
let info =
import_image(registry, &tmp_path, Some(tag.as_str()), blob_cache)
.await
.map_err(|e| BuildError::RegistryError {
message: format!(
"failed to import '{tag}' into local registry at {}: {e}",
registry.root().display()
),
})?;
info!(
tag = %tag,
digest = %info.digest,
"Imported into local registry"
);
}
Ok::<(), BuildError>(())
}
.await;
if let Err(e) = fs::remove_file(&tmp_path).await {
warn!(path = %tmp_path.display(), error = %e, "Failed to remove temp OCI archive");
}
import_result?;
}
}
Ok(built)
}
async fn resolve_target_os_and_backend(&mut self) -> Result<()> {
if self.target_os.is_some() {
return Ok(());
}
let zimage_path = self.options.zimagefile.clone().or_else(|| {
let candidate = self.context.join("ZImagefile");
candidate.exists().then_some(candidate)
});
let Some(path) = zimage_path else {
return Ok(());
};
let Ok(content) = fs::read_to_string(&path).await else {
return Ok(());
};
let Ok(zimage) = crate::zimage::parse_zimagefile(&content) else {
return Ok(());
};
if let Some(resolved) = zimage.resolve_target_os() {
let initial = crate::backend::ImageOs::Linux;
if resolved != initial {
info!(
"Re-detecting build backend for target OS {:?} (inferred from ZImagefile)",
resolved
);
self.backend = Some(crate::backend::detect_backend(resolved).await?);
}
self.target_os = Some(resolved);
}
Ok(())
}
async fn get_build_output(&self) -> Result<BuildOutput> {
if let Some(runtime) = &self.options.runtime {
debug!("Using runtime template: {}", runtime);
let content = get_template(*runtime);
return Ok(BuildOutput::Dockerfile(Dockerfile::parse(content)?));
}
if let Some(ref zimage_path) = self.options.zimagefile {
debug!("Reading ZImagefile: {}", zimage_path.display());
let content =
fs::read_to_string(zimage_path)
.await
.map_err(|e| BuildError::ContextRead {
path: zimage_path.clone(),
source: e,
})?;
let zimage = crate::zimage::parse_zimagefile(&content)?;
return self.handle_zimage(&zimage).await;
}
let auto_zimage_path = self.context.join("ZImagefile");
if auto_zimage_path.exists() {
debug!(
"Found ZImagefile in context: {}",
auto_zimage_path.display()
);
let content = fs::read_to_string(&auto_zimage_path).await.map_err(|e| {
BuildError::ContextRead {
path: auto_zimage_path,
source: e,
}
})?;
let zimage = crate::zimage::parse_zimagefile(&content)?;
return self.handle_zimage(&zimage).await;
}
let dockerfile_path = self
.options
.dockerfile
.clone()
.unwrap_or_else(|| self.context.join("Dockerfile"));
debug!("Reading Dockerfile: {}", dockerfile_path.display());
let content =
fs::read_to_string(&dockerfile_path)
.await
.map_err(|e| BuildError::ContextRead {
path: dockerfile_path,
source: e,
})?;
Ok(BuildOutput::Dockerfile(Dockerfile::parse(&content)?))
}
async fn handle_zimage(&self, zimage: &crate::zimage::ZImage) -> Result<BuildOutput> {
if let Some(ref runtime_name) = zimage.runtime {
let rt = Runtime::from_name(runtime_name).ok_or_else(|| {
BuildError::zimagefile_validation(format!(
"unknown runtime '{runtime_name}' in ZImagefile"
))
})?;
let content = get_template(rt);
return Ok(BuildOutput::Dockerfile(Dockerfile::parse(content)?));
}
if zimage.wasm.is_some() {
return self.handle_wasm_build(zimage).await;
}
let resolved = self.resolve_build_directives(zimage).await?;
Ok(BuildOutput::Dockerfile(
crate::zimage::zimage_to_dockerfile(&resolved)?,
))
}
#[allow(clippy::too_many_lines)]
async fn handle_wasm_build(&self, zimage: &crate::zimage::ZImage) -> Result<BuildOutput> {
use crate::wasm_builder::{build_wasm, WasiTarget, WasmBuildConfig, WasmLanguage};
use zlayer_registry::wasm::WasiVersion;
use zlayer_registry::{export_wasm_as_oci, WasmExportConfig};
let wasm_config = zimage.wasm.as_ref().expect(
"handle_wasm_build invoked without a wasm section in ZImage; caller must check",
);
info!("ZImagefile specifies WASM mode, running WASM build");
let target = match wasm_config.target.as_str() {
"preview1" => WasiTarget::Preview1,
_ => WasiTarget::Preview2,
};
let language = wasm_config
.language
.as_deref()
.and_then(WasmLanguage::from_name);
if let Some(ref lang_str) = wasm_config.language {
if language.is_none() {
return Err(BuildError::zimagefile_validation(format!(
"unknown WASM language '{lang_str}'. Supported: rust, go, python, \
typescript, assemblyscript, c, zig"
)));
}
}
let mut config = WasmBuildConfig {
language,
target,
optimize: wasm_config.optimize,
opt_level: wasm_config
.opt_level
.clone()
.unwrap_or_else(|| "Oz".to_string()),
wit_path: wasm_config.wit.as_ref().map(PathBuf::from),
output_path: wasm_config.output.as_ref().map(PathBuf::from),
world: wasm_config.world.clone(),
features: wasm_config.features.clone(),
build_args: wasm_config.build_args.clone(),
pre_build: Vec::new(),
post_build: Vec::new(),
adapter: wasm_config.adapter.as_ref().map(PathBuf::from),
};
for cmd in &wasm_config.pre_build {
config.pre_build.push(zcommand_to_args(cmd));
}
for cmd in &wasm_config.post_build {
config.post_build.push(zcommand_to_args(cmd));
}
let result = build_wasm(&self.context, config).await?;
let language_name = result.language.name().to_string();
let wasm_path = result.wasm_path;
let size = result.size;
info!(
"WASM build complete: {} ({} bytes, optimized={})",
wasm_path.display(),
size,
wasm_config.optimize
);
if !wasm_config.oci {
info!(
"WASM OCI export skipped (wasm.oci = false); raw .wasm at {}",
wasm_path.display()
);
return Ok(BuildOutput::WasmArtifact {
wasm_path,
oci_path: None,
manifest_digest: None,
artifact_type: None,
language: language_name,
optimized: wasm_config.optimize,
size,
});
}
let module_name = self
.options
.tags
.first()
.map(|t| module_name_from_tag(t))
.or_else(|| {
wasm_path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.unwrap_or_else(|| "wasm-module".to_string());
let wasi_version = match target {
WasiTarget::Preview1 => Some(WasiVersion::Preview1),
WasiTarget::Preview2 => Some(WasiVersion::Preview2),
};
let annotations: HashMap<String, String> = zimage.labels.clone();
let export_config = WasmExportConfig {
wasm_path: wasm_path.clone(),
module_name: module_name.clone(),
wasi_version,
annotations,
};
let export =
export_wasm_as_oci(&export_config)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("failed to export WASM as OCI artifact: {e}"),
})?;
let layout_parent = wasm_path
.parent()
.map_or_else(|| self.context.clone(), Path::to_path_buf);
let oci_dir = layout_parent.join(format!("{module_name}-oci"));
write_wasm_oci_layout(&oci_dir, &export, &module_name).await?;
info!(
manifest_digest = %export.manifest_digest,
artifact_type = %export.artifact_type,
oci_path = %oci_dir.display(),
"WASM OCI artifact written"
);
Ok(BuildOutput::WasmArtifact {
wasm_path,
oci_path: Some(oci_dir),
manifest_digest: Some(export.manifest_digest),
artifact_type: Some(export.artifact_type),
language: language_name,
optimized: wasm_config.optimize,
size,
})
}
async fn resolve_build_directives(
&self,
zimage: &crate::zimage::ZImage,
) -> Result<crate::zimage::ZImage> {
let mut resolved = zimage.clone();
if let Some(ref build_ctx) = resolved.build {
let tag = self.run_nested_build(build_ctx, "toplevel").await?;
resolved.base = Some(tag);
resolved.build = None;
}
if let Some(ref mut stages) = resolved.stages {
for (name, stage) in stages.iter_mut() {
if let Some(ref build_ctx) = stage.build {
let tag = self.run_nested_build(build_ctx, name).await?;
stage.base = Some(tag);
stage.build = None;
}
}
}
Ok(resolved)
}
fn run_nested_build<'a>(
&'a self,
build_ctx: &'a crate::zimage::types::ZBuildContext,
stage_name: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
Box::pin(self.run_nested_build_inner(build_ctx, stage_name))
}
async fn run_nested_build_inner(
&self,
build_ctx: &crate::zimage::types::ZBuildContext,
stage_name: &str,
) -> Result<String> {
let context_dir = build_ctx.context_dir(&self.context);
if !context_dir.exists() {
return Err(BuildError::ContextRead {
path: context_dir,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"build context directory not found for build directive in '{stage_name}'"
),
),
});
}
info!(
"Building nested image for '{}' from context: {}",
stage_name,
context_dir.display()
);
let tag = format!(
"zlayer-build-dep-{}:{}",
stage_name,
chrono_lite_timestamp()
);
let mut nested = ImageBuilder::new_with_os(&context_dir, self.target_os).await?;
nested = nested.tag(&tag);
if let Some(file) = build_ctx.file() {
let file_path = context_dir.join(file);
if std::path::Path::new(file).extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")
}) || file.starts_with("ZImagefile")
{
nested = nested.zimagefile(file_path);
} else {
nested = nested.dockerfile(file_path);
}
}
for (key, value) in build_ctx.args() {
nested = nested.build_arg(&key, &value);
}
if let Some(ref reg) = self.options.default_registry {
nested = nested.default_registry(reg.clone());
}
let result = nested.build().await?;
info!(
"Nested build for '{}' completed: {}",
stage_name, result.image_id
);
Ok(tag)
}
#[cfg(feature = "local-registry")]
async fn push_wasm_oci(&self, wasm_path: &Path, oci_dir: &Path) -> Result<()> {
use zlayer_registry::wasm::WasiVersion;
use zlayer_registry::{export_wasm_as_oci, BlobCache, ImagePuller, WasmExportConfig};
let module_name = self
.options
.tags
.first()
.map(|t| module_name_from_tag(t))
.or_else(|| {
wasm_path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.unwrap_or_else(|| "wasm-module".to_string());
let export_config = WasmExportConfig {
wasm_path: wasm_path.to_path_buf(),
module_name,
wasi_version: None::<WasiVersion>,
annotations: HashMap::new(),
};
let export =
export_wasm_as_oci(&export_config)
.await
.map_err(|e| BuildError::RegistryError {
message: format!(
"failed to re-export WASM for push from {}: {e}",
wasm_path.display()
),
})?;
let cache = BlobCache::new().map_err(|e| BuildError::RegistryError {
message: format!("failed to create blob cache for WASM push: {e}"),
})?;
let puller = ImagePuller::new(cache);
for tag in &self.options.tags {
if !tag_has_registry_host(tag) {
info!(
"Skipping WASM push for bare tag '{}' (no registry host); \
OCI layout still available at {}",
tag,
oci_dir.display()
);
continue;
}
let oci_auth = Self::resolve_wasm_push_auth(self.options.registry_auth.as_ref());
info!("Pushing WASM artifact: {}", tag);
let push_result = puller
.push_wasm(tag, &export, &oci_auth)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("failed to push WASM artifact '{tag}': {e}"),
})?;
info!(
"Pushed WASM artifact: {} (manifest digest: {})",
tag, push_result.manifest_digest
);
}
Ok(())
}
#[cfg(feature = "local-registry")]
fn resolve_wasm_push_auth(auth: Option<&RegistryAuth>) -> zlayer_registry::RegistryAuth {
match auth {
Some(a) => zlayer_registry::RegistryAuth::Basic(a.username.clone(), a.password.clone()),
None => zlayer_registry::RegistryAuth::Anonymous,
}
}
fn send_event(&self, event: BuildEvent) {
if let Some(tx) = &self.event_tx {
let _ = tx.send(event);
}
}
}
fn chrono_lite_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}
fn zcommand_to_args(cmd: &crate::zimage::ZCommand) -> Vec<String> {
match cmd {
crate::zimage::ZCommand::Shell(s) => {
vec!["/bin/sh".to_string(), "-c".to_string(), s.clone()]
}
crate::zimage::ZCommand::Exec(args) => args.clone(),
}
}
fn module_name_from_tag(tag: &str) -> String {
let last_segment = tag.rsplit('/').next().unwrap_or(tag);
let without_tag = last_segment.split(':').next().unwrap_or(last_segment);
let without_digest = without_tag.split('@').next().unwrap_or(without_tag);
without_digest.to_string()
}
#[cfg(feature = "local-registry")]
fn tag_has_registry_host(tag: &str) -> bool {
if !tag.contains('/') {
return false;
}
let Some(first) = tag.split('/').next() else {
return false;
};
first.contains('.') || first.contains(':') || first == "localhost"
}
async fn write_wasm_oci_layout(
oci_dir: &Path,
export: &zlayer_registry::WasmExportResult,
ref_name: &str,
) -> Result<()> {
let map_io = |path: PathBuf| {
move |e: std::io::Error| BuildError::ContextRead {
path: path.clone(),
source: e,
}
};
fs::create_dir_all(oci_dir)
.await
.map_err(map_io(oci_dir.to_path_buf()))?;
let layout_marker = oci_dir.join("oci-layout");
let oci_layout = serde_json::json!({ "imageLayoutVersion": "1.0.0" });
fs::write(
&layout_marker,
serde_json::to_vec_pretty(&oci_layout).map_err(|e| BuildError::RegistryError {
message: format!("failed to serialize oci-layout marker: {e}"),
})?,
)
.await
.map_err(map_io(layout_marker.clone()))?;
let blobs_dir = oci_dir.join("blobs").join("sha256");
fs::create_dir_all(&blobs_dir)
.await
.map_err(map_io(blobs_dir.clone()))?;
let write_blob = |digest: &str, data: &[u8]| {
let hash = digest.strip_prefix("sha256:").unwrap_or(digest).to_string();
let path = blobs_dir.join(hash);
let data = data.to_vec();
async move {
fs::write(&path, &data)
.await
.map_err(map_io(path.clone()))?;
Ok::<(), BuildError>(())
}
};
write_blob(&export.config_digest, &export.config_blob).await?;
write_blob(&export.wasm_layer_digest, &export.wasm_binary).await?;
write_blob(&export.manifest_digest, &export.manifest_json).await?;
let index = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": export.manifest_digest,
"size": export.manifest_size,
"artifactType": export.artifact_type,
"annotations": {
"org.opencontainers.image.ref.name": ref_name,
}
}]
});
let index_path = oci_dir.join("index.json");
fs::write(
&index_path,
serde_json::to_vec_pretty(&index).map_err(|e| BuildError::RegistryError {
message: format!("failed to serialize OCI index.json: {e}"),
})?,
)
.await
.map_err(map_io(index_path.clone()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_auth_new() {
let auth = RegistryAuth::new("user", "pass");
assert_eq!(auth.username, "user");
assert_eq!(auth.password, "pass");
}
#[test]
fn test_build_options_default() {
let opts = BuildOptions::default();
assert!(opts.dockerfile.is_none());
assert!(opts.zimagefile.is_none());
assert!(opts.runtime.is_none());
assert!(opts.build_args.is_empty());
assert!(opts.target.is_none());
assert!(opts.tags.is_empty());
assert!(!opts.no_cache);
assert!(!opts.push);
assert!(!opts.squash);
assert!(opts.layers); assert!(opts.cache_from.is_none());
assert!(opts.cache_to.is_none());
assert!(opts.cache_ttl.is_none());
#[cfg(feature = "cache")]
assert!(opts.cache_backend_config.is_none());
}
fn create_test_builder() -> ImageBuilder {
ImageBuilder {
context: PathBuf::from("/tmp/test"),
options: BuildOptions::default(),
executor: BuildahExecutor::with_path("/usr/bin/buildah"),
event_tx: None,
target_os: None,
backend: None,
#[cfg(feature = "cache")]
cache_backend: None,
#[cfg(feature = "local-registry")]
local_registry: None,
}
}
#[test]
fn test_builder_chaining() {
let mut builder = create_test_builder();
builder = builder
.dockerfile("./Dockerfile.test")
.runtime(Runtime::Node20)
.build_arg("VERSION", "1.0")
.target("builder")
.tag("myapp:latest")
.tag("myapp:v1")
.no_cache()
.squash()
.format("oci");
assert_eq!(
builder.options.dockerfile,
Some(PathBuf::from("./Dockerfile.test"))
);
assert_eq!(builder.options.runtime, Some(Runtime::Node20));
assert_eq!(
builder.options.build_args.get("VERSION"),
Some(&"1.0".to_string())
);
assert_eq!(builder.options.target, Some("builder".to_string()));
assert_eq!(builder.options.tags.len(), 2);
assert!(builder.options.no_cache);
assert!(builder.options.squash);
assert_eq!(builder.options.format, Some("oci".to_string()));
}
#[test]
fn test_builder_push_with_auth() {
let mut builder = create_test_builder();
builder = builder.push(RegistryAuth::new("user", "pass"));
assert!(builder.options.push);
assert!(builder.options.registry_auth.is_some());
let auth = builder.options.registry_auth.unwrap();
assert_eq!(auth.username, "user");
assert_eq!(auth.password, "pass");
}
#[test]
fn test_builder_push_without_auth() {
let mut builder = create_test_builder();
builder = builder.push_without_auth();
assert!(builder.options.push);
assert!(builder.options.registry_auth.is_none());
}
#[test]
fn test_builder_layers() {
let mut builder = create_test_builder();
assert!(builder.options.layers);
builder = builder.layers(false);
assert!(!builder.options.layers);
builder = builder.layers(true);
assert!(builder.options.layers);
}
#[test]
fn test_builder_cache_from() {
let mut builder = create_test_builder();
assert!(builder.options.cache_from.is_none());
builder = builder.cache_from("registry.example.com/myapp:cache");
assert_eq!(
builder.options.cache_from,
Some("registry.example.com/myapp:cache".to_string())
);
}
#[test]
fn test_builder_cache_to() {
let mut builder = create_test_builder();
assert!(builder.options.cache_to.is_none());
builder = builder.cache_to("registry.example.com/myapp:cache");
assert_eq!(
builder.options.cache_to,
Some("registry.example.com/myapp:cache".to_string())
);
}
#[test]
fn test_builder_cache_ttl() {
use std::time::Duration;
let mut builder = create_test_builder();
assert!(builder.options.cache_ttl.is_none());
builder = builder.cache_ttl(Duration::from_secs(3600));
assert_eq!(builder.options.cache_ttl, Some(Duration::from_secs(3600)));
}
#[test]
fn test_builder_cache_options_chaining() {
use std::time::Duration;
let builder = create_test_builder()
.layers(true)
.cache_from("registry.example.com/cache:input")
.cache_to("registry.example.com/cache:output")
.cache_ttl(Duration::from_secs(7200))
.no_cache();
assert!(builder.options.layers);
assert_eq!(
builder.options.cache_from,
Some("registry.example.com/cache:input".to_string())
);
assert_eq!(
builder.options.cache_to,
Some("registry.example.com/cache:output".to_string())
);
assert_eq!(builder.options.cache_ttl, Some(Duration::from_secs(7200)));
assert!(builder.options.no_cache);
}
#[test]
fn test_chrono_lite_timestamp() {
let ts = chrono_lite_timestamp();
let parsed: u64 = ts.parse().expect("Should be a valid u64");
assert!(parsed > 1_700_000_000);
}
}