use crate::core::store::layer_builder::LayerEntry;
use crate::core::store::overlay_export;
use crate::core::types::{
ForjarConfig, ImageBuildMetrics, ImageBuildPlan, LayerMetric, LayerStrategy, OciLayerConfig,
Resource,
};
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_build(
file: &std::path::Path,
resource: &str,
load: bool,
push: bool,
far: bool,
sandbox: bool,
json: bool,
) -> Result<(), String> {
if json {
reject_json_stdout_conflicts(load, push, far, sandbox)?;
}
let config = super::helpers::parse_and_validate(file)?;
let res = config
.resources
.get(resource)
.ok_or_else(|| format!("resource '{resource}' not found"))?;
if !matches!(res.resource_type, crate::core::types::ResourceType::Image) {
return Err(format!("resource '{resource}' is not type: image"));
}
let plan = build_plan_from_resource(resource, res, &config)?;
let output_dir = std::path::Path::new("state/images").join(resource);
std::fs::create_dir_all(&output_dir).map_err(|e| format!("create output dir: {e}"))?;
if sandbox {
return cmd_build_sandbox(resource, &plan, &config, &output_dir, load, push, far);
}
let layer_entries = collect_layer_entries(&plan, &config)?;
let input_hash = compute_layer_input_hash(&layer_entries);
if let Some(cached) = check_build_cache(&output_dir, &input_hash) {
if json {
print_build_json(&plan.tag, &output_dir, None, true);
return Ok(());
}
println!("\nBuilding {resource} ({}) — CACHED", plan.tag);
println!(" {cached}");
println!(" Input hash: {input_hash}");
run_distribution(resource, &plan.tag, &output_dir, load, push, far)?;
return Ok(());
}
let start = std::time::Instant::now();
let result = crate::core::store::image_assembler::assemble_image(
&plan,
&layer_entries,
&output_dir,
&OciLayerConfig::default(),
None, )?;
let duration = start.elapsed();
if !json {
print_build_report(resource, &plan.tag, &result, &output_dir, duration);
}
record_build_metrics(&plan.tag, &result, duration, &output_dir);
write_build_cache(&output_dir, &input_hash);
if json {
print_build_json(&plan.tag, &output_dir, Some(&result), false);
return Ok(());
}
run_distribution(resource, &plan.tag, &output_dir, load, push, far)
}
fn print_build_report(
resource: &str,
tag: &str,
result: &crate::core::store::image_assembler::AssembledImage,
output_dir: &std::path::Path,
duration: std::time::Duration,
) {
println!("\nBuilding {resource} ({tag})");
for (i, layer) in result.layers.iter().enumerate() {
println!(
" Layer {}/{}: {} files, {} -> {} bytes",
i + 1,
result.layers.len(),
layer.file_count,
layer.uncompressed_size,
layer.compressed_size
);
}
println!(
"\n Image: {} ({} layers, {} bytes)",
tag,
result.layers.len(),
result.total_size
);
println!(" Layout: {}", output_dir.display());
println!(" Built in {:.1}s", duration.as_secs_f64());
}
fn record_build_metrics(
tag: &str,
result: &crate::core::store::image_assembler::AssembledImage,
duration: std::time::Duration,
output_dir: &std::path::Path,
) {
let metrics = ImageBuildMetrics {
tag: tag.to_string(),
layer_count: result.layers.len(),
total_size: result.total_size,
layers: result
.layers
.iter()
.map(|l| LayerMetric {
file_count: l.file_count,
uncompressed_size: l.uncompressed_size,
compressed_size: l.compressed_size,
})
.collect(),
duration_secs: duration.as_secs_f64(),
built_at: crate::tripwire::eventlog::now_iso8601(),
forjar_version: env!("CARGO_PKG_VERSION").to_string(),
target_arch: std::env::consts::ARCH.to_string(),
};
if let Err(e) = metrics.write_to(output_dir) {
eprintln!(" warning: {e}");
}
}
fn reject_json_stdout_conflicts(
load: bool,
push: bool,
far: bool,
sandbox: bool,
) -> Result<(), String> {
let blocking = [
(load, "--load"),
(push, "--push"),
(far, "--far"),
(sandbox, "--sandbox"),
];
if let Some((_, flag)) = blocking.into_iter().find(|(on, _)| *on) {
return Err(format!(
"--json is not supported together with {flag}: {flag} streams \
human-readable progress on stdout, which would not parse as JSON. \
Run the build with --json first, then {flag} separately."
));
}
Ok(())
}
fn run_distribution(
resource: &str,
tag: &str,
output_dir: &std::path::Path,
load: bool,
push: bool,
far: bool,
) -> Result<(), String> {
if load {
cmd_build_load(output_dir)?;
}
if push {
cmd_build_push(tag, output_dir)?;
}
if far {
cmd_build_far(resource, output_dir)?;
}
Ok(())
}
fn print_build_json(
tag: &str,
output_dir: &std::path::Path,
result: Option<&crate::core::store::image_assembler::AssembledImage>,
cached: bool,
) {
let layers: Vec<serde_json::Value> = result
.map(|r| {
r.layers
.iter()
.map(|l| {
serde_json::json!({
"file_count": l.file_count,
"uncompressed_size": l.uncompressed_size,
"compressed_size": l.compressed_size,
"digest": l.digest,
})
})
.collect()
})
.unwrap_or_default();
let doc = serde_json::json!({
"tag": tag,
"layout": output_dir.display().to_string(),
"layout_exists": output_dir.join("index.json").exists(),
"cached": cached,
"layers": layers,
"layer_count": result.map(|r| r.layers.len()),
"total_size": result.map(|r| r.total_size),
});
println!(
"{}",
serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string())
);
}
#[allow(clippy::too_many_arguments)]
fn cmd_build_sandbox(
resource: &str,
plan: &ImageBuildPlan,
config: &ForjarConfig,
output_dir: &std::path::Path,
load: bool,
push: bool,
far: bool,
) -> Result<(), String> {
use crate::core::store::container_build;
let apply_scripts: Vec<String> = config
.resources
.iter()
.filter(|(_, r)| !matches!(r.resource_type, crate::core::types::ResourceType::Image))
.filter_map(|(_, r)| {
let resolved = crate::core::resolver::resolve_resource_templates(
r,
&config.params,
&config.machines,
)
.ok()?;
crate::core::codegen::apply_script(&resolved).ok()
})
.collect();
println!("\nBuilding {resource} ({}) via container sandbox", plan.tag);
println!(" Scripts: {}", apply_scripts.len());
let result = container_build::build_image_in_container(plan, &apply_scripts, output_dir)?;
println!(" {}", container_build::format_container_build(&result));
println!(" Layout: {}", output_dir.display());
run_distribution(resource, &plan.tag, output_dir, load, push, far)
}
fn resource_image_reference(name: &str, res: &Resource) -> String {
let declared = res.tag.as_deref().map(str::trim).filter(|t| !t.is_empty());
if let Some(full) = declared.filter(|t| t.contains('/') || t.contains(':')) {
return full.to_string();
}
let version = declared
.or(res.version.as_deref())
.unwrap_or(DEFAULT_IMAGE_TAG);
let image_name = res.name.as_deref().unwrap_or(name);
format!("{image_name}:{version}")
}
const DEFAULT_IMAGE_TAG: &str = "latest";
fn build_plan_from_resource(
name: &str,
res: &Resource,
config: &ForjarConfig,
) -> Result<ImageBuildPlan, String> {
let _ = config;
let image_reference = resource_image_reference(name, res);
let mut layers = Vec::new();
if let Some(ref base) = res.image {
let base_dir = std::path::Path::new("state/images").join(base.replace([':', '/'], "_"));
if base_dir.exists() {
if let Ok(base_layers) = crate::core::store::base_image::extract_base_layers(&base_dir)
{
println!(
" {}",
crate::core::store::base_image::format_base_info(base, &base_layers)
);
}
}
}
let all_paths: Vec<String> = res.path.iter().cloned().collect();
let (config_paths, app_paths) = split_paths_by_type(&all_paths);
if !config_paths.is_empty() && !app_paths.is_empty() {
layers.push(LayerStrategy::Files { paths: app_paths });
layers.push(LayerStrategy::Files {
paths: config_paths,
});
} else {
layers.push(LayerStrategy::Files { paths: all_paths });
}
Ok(ImageBuildPlan {
tag: image_reference,
base_image: res.image.clone(),
layers,
labels: vec![],
entrypoint: res.command.clone().map(|e| vec![e]),
})
}
fn collect_layer_entries(
plan: &ImageBuildPlan,
config: &ForjarConfig,
) -> Result<Vec<Vec<LayerEntry>>, String> {
plan.layers
.iter()
.map(|strategy| collect_strategy_entries(strategy, config))
.collect()
}
fn collect_strategy_entries(
strategy: &LayerStrategy,
config: &ForjarConfig,
) -> Result<Vec<LayerEntry>, String> {
match strategy {
LayerStrategy::Files { paths } => Ok(collect_file_entries(paths, config)),
LayerStrategy::Packages { names } => {
let content = names.join("\n");
Ok(vec![LayerEntry::file(
"var/lib/forjar/packages.list",
content.as_bytes(),
0o644,
)])
}
LayerStrategy::Build {
command: _,
workdir,
} => collect_build_entries(workdir.as_deref()),
LayerStrategy::Derivation { store_path } => collect_derivation_entries(store_path),
}
}
fn collect_file_entries(paths: &[String], config: &ForjarConfig) -> Vec<LayerEntry> {
paths
.iter()
.map(|path| {
if let Some(res) = config
.resources
.values()
.find(|r| r.path.as_deref() == Some(path))
{
let content = res.content.as_deref().unwrap_or("").as_bytes();
let mode = res
.mode
.as_deref()
.and_then(|m| u32::from_str_radix(m, 8).ok())
.unwrap_or(0o644);
LayerEntry::file(path, content, mode)
} else {
LayerEntry::file(path, b"", 0o644)
}
})
.collect()
}
fn collect_build_entries(workdir: Option<&str>) -> Result<Vec<LayerEntry>, String> {
let overlay_dir = workdir
.map(std::path::Path::new)
.unwrap_or_else(|| std::path::Path::new("/tmp/forjar-overlay"));
if overlay_dir.exists() {
let scan = overlay_export::scan_overlay_upper(overlay_dir, overlay_dir)
.map_err(|e| format!("overlay scan: {e}"))?;
Ok(overlay_export::merge_overlay_entries(&scan))
} else {
Ok(vec![])
}
}
fn collect_derivation_entries(store_path: &str) -> Result<Vec<LayerEntry>, String> {
let p = std::path::Path::new(store_path);
if p.exists() {
let scan = overlay_export::scan_overlay_upper(p, p)
.map_err(|e| format!("derivation scan: {e}"))?;
Ok(scan.entries)
} else {
Ok(vec![])
}
}
fn split_paths_by_type(paths: &[String]) -> (Vec<String>, Vec<String>) {
let config_exts = [
".yaml",
".yml",
".toml",
".json",
".conf",
".cfg",
".ini",
".env",
".properties",
];
let mut config_paths = Vec::new();
let mut app_paths = Vec::new();
for path in paths {
let lower = path.to_lowercase();
if config_exts.iter().any(|ext| lower.ends_with(ext)) {
config_paths.push(path.clone());
} else {
app_paths.push(path.clone());
}
}
(config_paths, app_paths)
}
fn compute_layer_input_hash(layer_entries: &[Vec<LayerEntry>]) -> String {
let mut hasher = blake3::Hasher::new();
for entries in layer_entries {
for entry in entries {
hasher.update(entry.path.as_bytes());
hasher.update(&entry.content);
hasher.update(&entry.mode.to_le_bytes());
}
}
hasher.finalize().to_hex().to_string()
}
fn check_build_cache(output_dir: &std::path::Path, input_hash: &str) -> Option<String> {
let cache_path = output_dir.join("build-cache.hash");
let cached_hash = std::fs::read_to_string(&cache_path).ok()?;
if cached_hash.trim() == input_hash {
let metrics_path = output_dir.join("build-metrics.json");
if metrics_path.exists() {
return Some(format!(
"Layer inputs unchanged (hash: {:.16}…), skipping rebuild",
input_hash
));
}
}
None
}
fn write_build_cache(output_dir: &std::path::Path, input_hash: &str) {
let cache_path = output_dir.join("build-cache.hash");
let _ = std::fs::write(cache_path, input_hash);
}
#[cfg(test)]
pub(crate) fn test_build_plan_from_resource(
name: &str,
res: &Resource,
config: &ForjarConfig,
) -> Result<ImageBuildPlan, String> {
build_plan_from_resource(name, res, config)
}
#[cfg(test)]
pub(crate) fn test_collect_layer_entries(
plan: &ImageBuildPlan,
config: &ForjarConfig,
) -> Result<Vec<Vec<LayerEntry>>, String> {
collect_layer_entries(plan, config)
}
#[cfg(test)]
pub(crate) fn test_split_paths_by_type(paths: &[String]) -> (Vec<String>, Vec<String>) {
split_paths_by_type(paths)
}
use super::build_distribution::{cmd_build_far, cmd_build_load, cmd_build_push};