refraction-forger/crates/forge-engine/src/lib.rs
Till Wegmueller 7d97061e0f
Eliminate rootfs copy: populate directly into target filesystem
Restructure the QCOW2 build pipeline so the target filesystem is created
and mounted before Phase 1, allowing rootfs population directly into it.
This removes the wasteful and fragile copy_rootfs step that was the source
of merged-/usr symlink breakage and fixes the ZFS shadow-mount bug where
the BE dataset was mounted over the staging root.

New flow: Phase 2 prepare → Phase 1 populate → Phase 2 finalize → cleanup.
OCI/Artifact targets are unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:16:35 +01:00

131 lines
4.4 KiB
Rust

// thiserror/miette derive macros generate code that triggers false-positive unused_assignments
#![allow(unused_assignments)]
pub mod error;
pub mod phase1;
pub mod phase2;
pub mod tools;
use std::path::Path;
use error::ForgeError;
use spec_parser::schema::{ImageSpec, Target, TargetKind};
use tools::ToolRunner;
use tracing::info;
/// Context for running a build.
pub struct BuildContext<'a> {
/// The resolved and profile-filtered image spec.
pub spec: &'a ImageSpec,
/// Directory containing overlay source files (images/files/).
pub files_dir: &'a Path,
/// Output directory for build artifacts.
pub output_dir: &'a Path,
/// Tool runner for executing external commands.
pub runner: &'a dyn ToolRunner,
}
impl<'a> BuildContext<'a> {
/// Build a specific target by name, or all targets if name is None.
pub async fn build(&self, target_name: Option<&str>) -> Result<(), ForgeError> {
let targets = self.select_targets(target_name)?;
std::fs::create_dir_all(self.output_dir)?;
for target in targets {
info!(target = %target.name, kind = %target.kind, "Building target");
match target.kind {
TargetKind::Qcow2 => {
self.build_qcow2(target).await?;
}
_ => {
// OCI/Artifact: Phase 1 creates its own staging dir, Phase 2 packs from it
let phase1_result =
phase1::execute(self.spec, self.files_dir, self.runner).await?;
phase2::execute(
target,
&phase1_result.staging_root,
self.files_dir,
self.output_dir,
)
.await?;
}
}
info!(target = %target.name, "Target built successfully");
}
Ok(())
}
/// QCOW2 build: prepare disk → populate rootfs directly → finalize → cleanup.
async fn build_qcow2(&self, target: &Target) -> Result<(), ForgeError> {
// Phase 2 prepare: create disk image, partition/format, mount target filesystem
let prepared =
phase2::qcow2::prepare_qcow2(target, self.output_dir, self.runner).await?;
// Phase 1: populate directly into the mounted target filesystem
let phase1_result =
phase1::execute_into(self.spec, self.files_dir, self.runner, prepared.root_mount())
.await;
// Phase 2 finalize: install bootloader, unmount (only if Phase 1 succeeded)
let finalize_result = if phase1_result.is_ok() {
phase2::qcow2::finalize_qcow2(&prepared, self.runner).await
} else {
Ok(())
};
// Cleanup always runs: detach loopback, convert if everything succeeded
let convert = phase1_result.is_ok() && finalize_result.is_ok();
let cleanup_result =
phase2::qcow2::cleanup_qcow2(prepared, convert, self.runner).await;
// Propagate errors in order of occurrence
phase1_result?;
finalize_result?;
cleanup_result?;
// Auto-push to OCI registry if configured
phase2::push_qcow2_if_configured(target, self.output_dir).await?;
Ok(())
}
fn select_targets(&self, target_name: Option<&str>) -> Result<Vec<&Target>, ForgeError> {
match target_name {
Some(name) => {
let target = self
.spec
.targets
.iter()
.find(|t| t.name == name)
.ok_or_else(|| {
let available = self
.spec
.targets
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>()
.join(", ");
ForgeError::TargetNotFound {
name: name.to_string(),
available,
}
})?;
Ok(vec![target])
}
None => Ok(self.spec.targets.iter().collect()),
}
}
}
/// List available targets from a spec.
pub fn list_targets(spec: &ImageSpec) -> Vec<(&str, &TargetKind)> {
spec.targets
.iter()
.map(|t| (t.name.as_str(), &t.kind))
.collect()
}