use super::convergence_container::detect_container_runtime;
use super::image_assembler::{assemble_image, AssembledImage};
use super::overlay_export;
use crate::core::types::{ImageBuildPlan, OciLayerConfig};
use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub struct ContainerBuildResult {
pub image: AssembledImage,
pub runtime: String,
pub changed_files: usize,
pub duration_ms: u64,
}
pub fn build_image_in_container(
plan: &ImageBuildPlan,
apply_scripts: &[String],
output_dir: &Path,
) -> Result<ContainerBuildResult, String> {
let start = std::time::Instant::now();
let runtime = detect_container_runtime()
.ok_or_else(|| "no container runtime (docker/podman) available".to_string())?;
let base_image = plan.base_image.as_deref().unwrap_or("debian:bookworm-slim");
let container_name = format!("forjar-build-{}", plan.tag.replace([':', '/'], "-"));
start_container(&runtime, &container_name, base_image)?;
let exec_result = execute_scripts(&runtime, &container_name, apply_scripts);
if let Err(e) = exec_result {
cleanup_container(&runtime, &container_name);
return Err(format!("apply script failed: {e}"));
}
let unique_id = format!(
"{:x}{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
std::process::id(),
);
let extract_dir = std::env::temp_dir().join(format!("forjar-build-{unique_id}"));
std::fs::create_dir_all(&extract_dir).map_err(|e| format!("create extract dir: {e}"))?;
let changed = extract_changes(&runtime, &container_name, &extract_dir);
cleanup_container(&runtime, &container_name);
let changed_files = changed?;
let scan = overlay_export::scan_overlay_upper(&extract_dir, &extract_dir)
.map_err(|e| format!("overlay scan: {e}"))?;
let _ = std::fs::remove_dir_all(&extract_dir);
let entries = overlay_export::merge_overlay_entries(&scan);
let layer_entries = vec![entries];
let mut build_plan = plan.clone();
if build_plan.layers.is_empty() {
build_plan
.layers
.push(crate::core::types::LayerStrategy::Files {
paths: vec!["(container diff)".into()],
});
}
while build_plan.layers.len() < layer_entries.len() {
build_plan
.layers
.push(crate::core::types::LayerStrategy::Files {
paths: vec!["(container diff)".into()],
});
}
while build_plan.layers.len() > layer_entries.len() {
build_plan.layers.pop();
}
let image = assemble_image(
&build_plan,
&layer_entries,
output_dir,
&OciLayerConfig::default(),
None,
)?;
Ok(ContainerBuildResult {
image,
runtime,
changed_files,
duration_ms: start.elapsed().as_millis() as u64,
})
}
fn start_container(runtime: &str, container_name: &str, base_image: &str) -> Result<(), String> {
let output = Command::new(runtime)
.args([
"run",
"-d",
"--rm",
"--name",
container_name,
base_image,
"sleep",
"300",
])
.output()
.map_err(|e| format!("container start: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("container start failed: {}", stderr.trim()));
}
Ok(())
}
fn execute_scripts(runtime: &str, container_name: &str, scripts: &[String]) -> Result<(), String> {
use std::io::Write;
use std::process::Stdio;
for (i, script) in scripts.iter().enumerate() {
if script.is_empty() {
continue;
}
let mut child = Command::new(runtime)
.args(["exec", "-i", container_name, "bash"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("exec script {i}: {e}"))?;
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(script.as_bytes())
.map_err(|e| format!("stdin write: {e}"))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("wait script {i}: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"script {i} exit {}: {}",
output.status.code().unwrap_or(-1),
stderr.trim()
));
}
}
Ok(())
}
fn extract_changes(
runtime: &str,
container_name: &str,
extract_dir: &Path,
) -> Result<usize, String> {
let diff_output = Command::new(runtime)
.args(["diff", container_name])
.output()
.map_err(|e| format!("docker diff: {e}"))?;
if !diff_output.status.success() {
let stderr = String::from_utf8_lossy(&diff_output.stderr);
return Err(format!("docker diff failed: {}", stderr.trim()));
}
let diff_text = String::from_utf8_lossy(&diff_output.stdout);
let added_paths: Vec<&str> = diff_text
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with('A') {
Some(trimmed[2..].trim())
} else {
None
}
})
.collect();
let file_count = added_paths.len();
for path in &added_paths {
let local_rel = path.strip_prefix('/').unwrap_or(path);
let local_path = extract_dir.join(local_rel);
if let Some(parent) = local_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let src = format!("{container_name}:{path}");
let cp_result = Command::new(runtime)
.args(["cp", &src, &local_path.to_string_lossy()])
.output();
if let Ok(output) = cp_result {
if !output.status.success() {
continue;
}
}
}
Ok(file_count)
}
fn cleanup_container(runtime: &str, container_name: &str) {
let _ = Command::new(runtime)
.args(["rm", "-f", container_name])
.output();
}
pub fn format_container_build(result: &ContainerBuildResult) -> String {
format!(
"Container build ({runtime}): {files} files changed, {layers} layers, {size} bytes ({ms}ms)",
runtime = result.runtime,
files = result.changed_files,
layers = result.image.layers.len(),
size = result.image.total_size,
ms = result.duration_ms,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::store::layer_builder::LayerEntry;
use crate::core::types::{ImageBuildPlan, LayerStrategy, OciLayerConfig};
fn test_plan() -> ImageBuildPlan {
ImageBuildPlan {
tag: "test:latest".into(),
base_image: None,
layers: vec![LayerStrategy::Files {
paths: vec!["/test".into()],
}],
labels: vec![],
entrypoint: None,
}
}
#[test]
fn format_container_build_output() {
let tmp = tempfile::tempdir().unwrap();
let plan = test_plan();
let entries = vec![vec![LayerEntry::file("hello.txt", b"hello world", 0o644)]];
let output_dir = tmp.path().join("output");
std::fs::create_dir_all(&output_dir).unwrap();
let image = assemble_image(
&plan,
&entries,
&output_dir,
&OciLayerConfig::default(),
None,
)
.unwrap();
let result = ContainerBuildResult {
image,
runtime: "docker".into(),
changed_files: 3,
duration_ms: 1500,
};
let formatted = format_container_build(&result);
assert!(formatted.contains("docker"));
assert!(formatted.contains("3 files changed"));
assert!(formatted.contains("1 layers"));
assert!(formatted.contains("1500ms"));
}
#[test]
#[ignore] fn build_image_in_container_echo() {
let tmp = tempfile::tempdir().unwrap();
let output_dir = tmp.path().join("output");
std::fs::create_dir_all(&output_dir).unwrap();
let mut plan = test_plan();
plan.base_image = Some("debian:bookworm-slim".into());
let scripts = vec!["echo 'hello' > /test.txt".to_string()];
let result = build_image_in_container(&plan, &scripts, &output_dir);
assert!(result.is_ok() || result.is_err());
}
}