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>
This commit is contained in:
Till Wegmueller 2026-02-15 18:16:35 +01:00
parent d24dcc0363
commit 7d97061e0f
No known key found for this signature in database
6 changed files with 417 additions and 310 deletions

View file

@ -35,19 +35,24 @@ impl<'a> BuildContext<'a> {
for target in targets { for target in targets {
info!(target = %target.name, kind = %target.kind, "Building target"); info!(target = %target.name, kind = %target.kind, "Building target");
// Phase 1: Assemble rootfs match target.kind {
let phase1_result = TargetKind::Qcow2 => {
phase1::execute(self.spec, self.files_dir, self.runner).await?; 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?;
// Phase 2: Produce target artifact phase2::execute(
phase2::execute( target,
target, &phase1_result.staging_root,
&phase1_result.staging_root, self.files_dir,
self.files_dir, self.output_dir,
self.output_dir, )
self.runner, .await?;
) }
.await?; }
info!(target = %target.name, "Target built successfully"); info!(target = %target.name, "Target built successfully");
} }
@ -55,6 +60,40 @@ impl<'a> BuildContext<'a> {
Ok(()) 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> { fn select_targets(&self, target_name: Option<&str>) -> Result<Vec<&Target>, ForgeError> {
match target_name { match target_name {
Some(name) => { Some(name) => {

View file

@ -21,45 +21,57 @@ pub struct Phase1Result {
pub _staging_dir: tempfile::TempDir, pub _staging_dir: tempfile::TempDir,
} }
/// Execute Phase 1: assemble a rootfs in a staging directory from the spec. /// Populate a rootfs into an existing directory (for QCOW2 targets that mount
/// the target filesystem first).
pub async fn execute_into(
spec: &ImageSpec,
files_dir: &Path,
runner: &dyn ToolRunner,
root: &Path,
) -> Result<(), ForgeError> {
let distro = DistroFamily::from_distro_str(spec.distro.as_deref());
info!(name = %spec.metadata.name, ?distro, "Starting Phase 1: rootfs assembly");
let root_str = root.to_str().unwrap();
info!(root = root_str, "Populating rootfs");
// 1. Extract base tarball
if let Some(ref base) = spec.base {
staging::extract_base_tarball(base, &root.to_path_buf())?;
}
// 2. Distro-specific package management
match distro {
DistroFamily::OmniOS => execute_ips(spec, root_str, files_dir, runner).await?,
DistroFamily::Ubuntu => execute_apt(spec, root_str, runner).await?,
}
// 3. Apply customizations (common)
for customization in &spec.customizations {
customizations::apply(customization, root)?;
}
// 4. Apply overlays (common)
for overlay_block in &spec.overlays {
overlays::apply_overlays(&overlay_block.actions, root, files_dir, runner).await?;
}
info!("Phase 1 complete: rootfs assembled");
Ok(())
}
/// Execute Phase 1: create a staging directory and assemble a rootfs from the spec.
/// ///
/// Dispatches to the appropriate distro-specific path based on the `distro` field, /// For OCI/Artifact targets that manage their own staging directory.
/// then applies common customizations and overlays. /// Delegates to `execute_into()` after creating the tempdir.
pub async fn execute( pub async fn execute(
spec: &ImageSpec, spec: &ImageSpec,
files_dir: &Path, files_dir: &Path,
runner: &dyn ToolRunner, runner: &dyn ToolRunner,
) -> Result<Phase1Result, ForgeError> { ) -> Result<Phase1Result, ForgeError> {
let distro = DistroFamily::from_distro_str(spec.distro.as_deref());
info!(name = %spec.metadata.name, ?distro, "Starting Phase 1: rootfs assembly");
// 1. Create staging directory
let (staging_dir, staging_root) = staging::create_staging()?; let (staging_dir, staging_root) = staging::create_staging()?;
let root = staging_root.to_str().unwrap();
info!(root, "Staging directory created");
// 2. Extract base tarball execute_into(spec, files_dir, runner, &staging_root).await?;
if let Some(ref base) = spec.base {
staging::extract_base_tarball(base, &staging_root)?;
}
// 3. Distro-specific package management
match distro {
DistroFamily::OmniOS => execute_ips(spec, root, files_dir, runner).await?,
DistroFamily::Ubuntu => execute_apt(spec, root, runner).await?,
}
// 4. Apply customizations (common)
for customization in &spec.customizations {
customizations::apply(customization, &staging_root)?;
}
// 5. Apply overlays (common)
for overlay_block in &spec.overlays {
overlays::apply_overlays(&overlay_block.actions, &staging_root, files_dir, runner).await?;
}
info!("Phase 1 complete: rootfs assembled");
Ok(Phase1Result { Ok(Phase1Result {
staging_root, staging_root,

View file

@ -10,18 +10,16 @@ use spec_parser::schema::{Target, TargetKind};
use tracing::info; use tracing::info;
use crate::error::ForgeError; use crate::error::ForgeError;
use crate::tools::ToolRunner;
/// Execute Phase 2: produce the target artifact from the staged rootfs. /// Execute Phase 2 for non-QCOW2 targets (OCI, Artifact).
/// ///
/// After building the artifact, if a `push_to` reference is set on a QCOW2 target, /// QCOW2 targets are handled directly by the orchestrator in `lib.rs` via
/// the QCOW2 file is automatically pushed as an OCI artifact. /// the prepare/finalize/cleanup flow.
pub async fn execute( pub async fn execute(
target: &Target, target: &Target,
staging_root: &Path, staging_root: &Path,
files_dir: &Path, files_dir: &Path,
output_dir: &Path, output_dir: &Path,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> { ) -> Result<(), ForgeError> {
info!( info!(
target = %target.name, target = %target.name,
@ -33,50 +31,55 @@ pub async fn execute(
TargetKind::Oci => { TargetKind::Oci => {
oci::build_oci(target, staging_root, output_dir)?; oci::build_oci(target, staging_root, output_dir)?;
} }
TargetKind::Qcow2 => {
qcow2::build_qcow2(target, staging_root, output_dir, runner).await?;
}
TargetKind::Artifact => { TargetKind::Artifact => {
artifact::build_artifact(target, staging_root, output_dir, files_dir)?; artifact::build_artifact(target, staging_root, output_dir, files_dir)?;
} }
} TargetKind::Qcow2 => {
unreachable!("QCOW2 targets are handled by the orchestrator via prepare/finalize/cleanup");
// Auto-push QCOW2 to OCI registry if push_to is set
if target.kind == TargetKind::Qcow2 {
if let Some(ref push_ref) = target.push_to {
let qcow2_path = output_dir.join(format!("{}.qcow2", target.name));
info!(
reference = %push_ref,
path = %qcow2_path.display(),
"Auto-pushing QCOW2 artifact to OCI registry"
);
let qcow2_data = std::fs::read(&qcow2_path).map_err(|e| {
ForgeError::ArtifactPushFailed {
reference: push_ref.clone(),
detail: format!("failed to read QCOW2 file: {e}"),
}
})?;
let metadata = forge_oci::artifact::Qcow2Metadata {
name: target.name.clone(),
version: "latest".to_string(),
architecture: "amd64".to_string(),
os: "linux".to_string(),
description: None,
};
let auth = forge_oci::artifact::resolve_ghcr_auth();
forge_oci::artifact::push_qcow2_artifact(push_ref, qcow2_data, &metadata, &auth, &[])
.await
.map_err(|e| ForgeError::ArtifactPushFailed {
reference: push_ref.clone(),
detail: e.to_string(),
})?;
} }
} }
info!(target = %target.name, "Phase 2 complete"); info!(target = %target.name, "Phase 2 complete");
Ok(()) Ok(())
} }
/// Push a QCOW2 file to an OCI registry if `push_to` is set on the target.
pub async fn push_qcow2_if_configured(
target: &Target,
output_dir: &Path,
) -> Result<(), ForgeError> {
if let Some(ref push_ref) = target.push_to {
let qcow2_path = output_dir.join(format!("{}.qcow2", target.name));
info!(
reference = %push_ref,
path = %qcow2_path.display(),
"Auto-pushing QCOW2 artifact to OCI registry"
);
let qcow2_data = std::fs::read(&qcow2_path).map_err(|e| {
ForgeError::ArtifactPushFailed {
reference: push_ref.clone(),
detail: format!("failed to read QCOW2 file: {e}"),
}
})?;
let metadata = forge_oci::artifact::Qcow2Metadata {
name: target.name.clone(),
version: "latest".to_string(),
architecture: "amd64".to_string(),
os: "linux".to_string(),
description: None,
};
let auth = forge_oci::artifact::resolve_ghcr_auth();
forge_oci::artifact::push_qcow2_artifact(push_ref, qcow2_data, &metadata, &auth, &[])
.await
.map_err(|e| ForgeError::ArtifactPushFailed {
reference: push_ref.clone(),
detail: e.to_string(),
})?;
}
Ok(())
}

View file

@ -5,22 +5,39 @@ use spec_parser::schema::Target;
use crate::error::ForgeError; use crate::error::ForgeError;
use crate::tools::ToolRunner; use crate::tools::ToolRunner;
/// Build a QCOW2 VM image, dispatching to the appropriate filesystem backend. /// A prepared QCOW2 disk image, ready for Phase 1 population.
#[derive(Debug)]
pub enum PreparedQcow2 {
Ext4(super::qcow2_ext4::PreparedExt4),
Zfs(super::qcow2_zfs::PreparedZfs),
}
impl PreparedQcow2 {
/// The path where the target filesystem is mounted; Phase 1 populates here.
pub fn root_mount(&self) -> &Path {
match self {
PreparedQcow2::Ext4(p) => p.root_mount(),
PreparedQcow2::Zfs(p) => p.root_mount(),
}
}
}
/// Phase 2 prepare: create disk image, partition/format, mount target filesystem.
/// ///
/// - `"zfs"` (default): ZFS pool with boot environment /// Dispatches to ext4 or ZFS based on `target.filesystem`.
/// - `"ext4"`: GPT+EFI+ext4 with GRUB bootloader pub async fn prepare_qcow2(
pub async fn build_qcow2(
target: &Target, target: &Target,
staging_root: &Path,
output_dir: &Path, output_dir: &Path,
runner: &dyn ToolRunner, runner: &dyn ToolRunner,
) -> Result<(), ForgeError> { ) -> Result<PreparedQcow2, ForgeError> {
match target.filesystem.as_deref().unwrap_or("zfs") { match target.filesystem.as_deref().unwrap_or("zfs") {
"zfs" => { "zfs" => {
super::qcow2_zfs::build_qcow2_zfs(target, staging_root, output_dir, runner).await let prepared = super::qcow2_zfs::prepare_zfs(target, output_dir, runner).await?;
Ok(PreparedQcow2::Zfs(prepared))
} }
"ext4" => { "ext4" => {
super::qcow2_ext4::build_qcow2_ext4(target, staging_root, output_dir, runner).await let prepared = super::qcow2_ext4::prepare_ext4(target, output_dir, runner).await?;
Ok(PreparedQcow2::Ext4(prepared))
} }
other => Err(ForgeError::UnsupportedFilesystem { other => Err(ForgeError::UnsupportedFilesystem {
fs_type: other.to_string(), fs_type: other.to_string(),
@ -29,6 +46,31 @@ pub async fn build_qcow2(
} }
} }
/// Phase 2 finalize: install bootloader, unmount.
pub async fn finalize_qcow2(
prepared: &PreparedQcow2,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
match prepared {
PreparedQcow2::Ext4(p) => super::qcow2_ext4::finalize_ext4(p, runner).await,
PreparedQcow2::Zfs(p) => super::qcow2_zfs::finalize_zfs(p, runner).await,
}
}
/// Cleanup: detach loopback, convert raw→qcow2 (if `convert`), remove raw file.
///
/// Always call this, even if earlier phases failed.
pub async fn cleanup_qcow2(
prepared: PreparedQcow2,
convert: bool,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
match prepared {
PreparedQcow2::Ext4(p) => super::qcow2_ext4::cleanup_ext4(p, convert, runner).await,
PreparedQcow2::Zfs(p) => super::qcow2_zfs::cleanup_zfs(p, convert, runner).await,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -53,12 +95,8 @@ mod tests {
let target = make_target(Some("btrfs")); let target = make_target(Some("btrfs"));
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async { let result = rt.block_on(async {
// We can't actually run the build, but we can test the dispatcher logic
// by checking the error for an unsupported filesystem
let tmpdir = tempfile::tempdir().unwrap(); let tmpdir = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
// Create a mock runner that always succeeds
use crate::tools::{ToolOutput, ToolRunner}; use crate::tools::{ToolOutput, ToolRunner};
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
@ -80,7 +118,7 @@ mod tests {
} }
} }
build_qcow2(&target, staging.path(), tmpdir.path(), &FailRunner).await prepare_qcow2(&target, tmpdir.path(), &FailRunner).await
}); });
assert!(result.is_err()); assert!(result.is_err());

View file

@ -1,4 +1,4 @@
use std::path::Path; use std::path::{Path, PathBuf};
use spec_parser::schema::Target; use spec_parser::schema::Target;
use tracing::info; use tracing::info;
@ -6,26 +6,33 @@ use tracing::info;
use crate::error::ForgeError; use crate::error::ForgeError;
use crate::tools::ToolRunner; use crate::tools::ToolRunner;
/// Build a QCOW2 VM image from the staged rootfs using ext4+GPT+GRUB. /// State for a prepared ext4 QCOW2 disk image, ready for Phase 1 population.
#[derive(Debug)]
pub struct PreparedExt4 {
pub raw_path: PathBuf,
pub qcow2_path: PathBuf,
pub device: String,
pub efi_part: String,
pub root_part: String,
pub mount_dir: tempfile::TempDir,
}
impl PreparedExt4 {
/// The path where the root partition is mounted; Phase 1 populates into this.
pub fn root_mount(&self) -> &Path {
self.mount_dir.path()
}
}
/// Phase 2 prepare: create raw disk, partition, format, and mount the root partition.
/// ///
/// Pipeline: /// Returns a `PreparedExt4` whose `root_mount()` is the mounted ext4 root —
/// 1. Create raw disk image of specified size /// Phase 1 should populate the rootfs directly into that directory.
/// 2. Attach loopback device + partprobe pub async fn prepare_ext4(
/// 3. Create GPT partition table (EFI + root)
/// 4. Format partitions (FAT32 for EFI, ext4 for root)
/// 5. Mount root, copy staging rootfs
/// 6. Mount EFI at /boot/efi
/// 7. Bind-mount /dev, /proc, /sys
/// 8. chroot grub-install
/// 9. chroot grub-mkconfig
/// 10. Unmount all, detach loopback
/// 11. Convert raw -> qcow2
pub async fn build_qcow2_ext4(
target: &Target, target: &Target,
staging_root: &Path,
output_dir: &Path, output_dir: &Path,
runner: &dyn ToolRunner, runner: &dyn ToolRunner,
) -> Result<(), ForgeError> { ) -> Result<PreparedExt4, ForgeError> {
let disk_size = target let disk_size = target
.disk_size .disk_size
.as_deref() .as_deref()
@ -34,127 +41,123 @@ pub async fn build_qcow2_ext4(
let raw_path = output_dir.join(format!("{}.raw", target.name)); let raw_path = output_dir.join(format!("{}.raw", target.name));
let qcow2_path = output_dir.join(format!("{}.qcow2", target.name)); let qcow2_path = output_dir.join(format!("{}.qcow2", target.name));
let raw_str = raw_path.to_str().unwrap(); let raw_str = raw_path.to_str().unwrap();
let qcow2_str = qcow2_path.to_str().unwrap();
info!(disk_size, "Step 1: Creating raw disk image"); info!(disk_size, "Step 1: Creating raw disk image");
crate::tools::qemu_img::create_raw(runner, raw_str, disk_size).await?; crate::tools::qemu_img::create_raw(runner, raw_str, disk_size).await?;
info!("Step 2: Attaching loopback device"); info!("Step 2: Attaching loopback device");
let device = crate::tools::loopback::attach(runner, raw_str).await?; let device = crate::tools::loopback::attach(runner, raw_str).await?;
// Re-read partition table after attaching loopback
let _ = crate::tools::loopback::partprobe(runner, &device).await; let _ = crate::tools::loopback::partprobe(runner, &device).await;
let result = async { info!(device = %device, "Step 3: Creating GPT partition table");
info!(device = %device, "Step 3: Creating GPT partition table"); let (efi_part, root_part) =
let (efi_part, root_part) = crate::tools::partition::create_gpt_efi_root(runner, &device).await?;
crate::tools::partition::create_gpt_efi_root(runner, &device).await?;
// Re-read partition table after creating partitions crate::tools::loopback::partprobe(runner, &device).await?;
crate::tools::loopback::partprobe(runner, &device).await?;
info!("Step 4: Formatting partitions"); info!("Step 4: Formatting partitions");
crate::tools::partition::mkfs_fat32(runner, &efi_part).await?; crate::tools::partition::mkfs_fat32(runner, &efi_part).await?;
crate::tools::partition::mkfs_ext4(runner, &root_part).await?; crate::tools::partition::mkfs_ext4(runner, &root_part).await?;
// Create a temporary mountpoint for the root partition let mount_dir = tempfile::tempdir().map_err(ForgeError::StagingSetup)?;
let mount_dir = tempfile::tempdir().map_err(ForgeError::StagingSetup)?; let mount_str = mount_dir.path().to_str().unwrap();
let mount_str = mount_dir.path().to_str().unwrap();
info!("Step 5: Mounting root partition and copying rootfs"); info!("Step 5: Mounting root partition at {}", mount_str);
crate::tools::partition::mount(runner, &root_part, mount_str).await?; crate::tools::partition::mount(runner, &root_part, mount_str).await?;
// Copy staging rootfs into mounted root Ok(PreparedExt4 {
copy_rootfs(staging_root, mount_dir.path(), runner).await?; raw_path,
qcow2_path,
info!("Step 6: Mounting EFI partition"); device,
let efi_mount = mount_dir.path().join("boot/efi"); efi_part,
std::fs::create_dir_all(&efi_mount)?; root_part,
let efi_mount_str = efi_mount.to_str().unwrap(); mount_dir,
crate::tools::partition::mount(runner, &efi_part, efi_mount_str).await?; })
info!("Step 7: Bind-mounting /dev, /proc, /sys");
let dev_mount = format!("{mount_str}/dev");
let proc_mount = format!("{mount_str}/proc");
let sys_mount = format!("{mount_str}/sys");
std::fs::create_dir_all(&dev_mount)?;
std::fs::create_dir_all(&proc_mount)?;
std::fs::create_dir_all(&sys_mount)?;
crate::tools::partition::bind_mount(runner, "/dev", &dev_mount).await?;
crate::tools::partition::bind_mount(runner, "/proc", &proc_mount).await?;
crate::tools::partition::bind_mount(runner, "/sys", &sys_mount).await?;
info!("Step 8: Installing GRUB bootloader");
runner
.run(
"chroot",
&[
mount_str,
"/usr/sbin/grub-install",
"--target=x86_64-efi",
"--efi-directory=/boot/efi",
"--no-nvram",
],
)
.await?;
info!("Step 9: Generating GRUB config");
runner
.run(
"chroot",
&[mount_str, "/usr/sbin/grub-mkconfig", "-o", "/boot/grub/grub.cfg"],
)
.await?;
info!("Step 10: Unmounting");
// Unmount in reverse order: bind mounts, EFI, root
crate::tools::partition::umount(runner, &sys_mount).await?;
crate::tools::partition::umount(runner, &proc_mount).await?;
crate::tools::partition::umount(runner, &dev_mount).await?;
crate::tools::partition::umount(runner, efi_mount_str).await?;
crate::tools::partition::umount(runner, mount_str).await?;
Ok::<(), ForgeError>(())
}
.await;
// Always try to detach loopback, even on error
info!("Detaching loopback device");
let detach_result = crate::tools::loopback::detach(runner, &device).await;
result?;
detach_result?;
info!("Step 11: Converting raw -> qcow2");
crate::tools::qemu_img::convert_to_qcow2(runner, raw_str, qcow2_str).await?;
// Clean up raw file
std::fs::remove_file(&raw_path).ok();
info!(path = %qcow2_path.display(), "QCOW2 (ext4) image created");
Ok(())
} }
/// Copy the staging rootfs into the mounted root partition. /// Phase 2 finalize: mount EFI, bind-mount pseudofs, install GRUB, unmount everything.
/// pub async fn finalize_ext4(
/// Uses `cp -a` (archive mode) to properly preserve symlinks, permissions, prepared: &PreparedExt4,
/// ownership, timestamps, and special files. This is critical for modern
/// distros with merged /usr where /lib, /bin, /sbin are symlinks.
async fn copy_rootfs(
src: &Path,
dest: &Path,
runner: &dyn ToolRunner, runner: &dyn ToolRunner,
) -> Result<(), ForgeError> { ) -> Result<(), ForgeError> {
let src_str = format!("{}/.", src.display()); let mount_str = prepared.mount_dir.path().to_str().unwrap();
let dest_str = dest.to_str().unwrap();
info!("Finalize step 1: Mounting EFI partition");
let efi_mount = prepared.mount_dir.path().join("boot/efi");
std::fs::create_dir_all(&efi_mount)?;
let efi_mount_str = efi_mount.to_str().unwrap();
crate::tools::partition::mount(runner, &prepared.efi_part, efi_mount_str).await?;
info!("Finalize step 2: Bind-mounting /dev, /proc, /sys");
let dev_mount = format!("{mount_str}/dev");
let proc_mount = format!("{mount_str}/proc");
let sys_mount = format!("{mount_str}/sys");
std::fs::create_dir_all(&dev_mount)?;
std::fs::create_dir_all(&proc_mount)?;
std::fs::create_dir_all(&sys_mount)?;
crate::tools::partition::bind_mount(runner, "/dev", &dev_mount).await?;
crate::tools::partition::bind_mount(runner, "/proc", &proc_mount).await?;
crate::tools::partition::bind_mount(runner, "/sys", &sys_mount).await?;
info!("Finalize step 3: Installing GRUB bootloader");
runner runner
.run("cp", &["-a", &src_str, dest_str]) .run(
.await "chroot",
.map_err(|_| ForgeError::Qcow2Build { &[
step: "copy_rootfs".to_string(), mount_str,
detail: format!("cp -a {}/. -> {}", src.display(), dest.display()), "/usr/sbin/grub-install",
})?; "--target=x86_64-efi",
"--efi-directory=/boot/efi",
"--no-nvram",
],
)
.await?;
info!("Finalize step 4: Generating GRUB config");
runner
.run(
"chroot",
&[mount_str, "/usr/sbin/grub-mkconfig", "-o", "/boot/grub/grub.cfg"],
)
.await?;
info!("Finalize step 5: Unmounting");
crate::tools::partition::umount(runner, &sys_mount).await?;
crate::tools::partition::umount(runner, &proc_mount).await?;
crate::tools::partition::umount(runner, &dev_mount).await?;
crate::tools::partition::umount(runner, efi_mount_str).await?;
crate::tools::partition::umount(runner, mount_str).await?;
Ok(())
}
/// Cleanup: detach loopback, convert raw→qcow2 (if `convert` is true), remove raw file.
///
/// Always runs, even if earlier phases failed — the loopback device must be detached.
pub async fn cleanup_ext4(
prepared: PreparedExt4,
convert: bool,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
info!("Cleanup: detaching loopback device");
let detach_result = crate::tools::loopback::detach(runner, &prepared.device).await;
if convert {
let raw_str = prepared.raw_path.to_str().unwrap();
let qcow2_str = prepared.qcow2_path.to_str().unwrap();
info!("Cleanup: converting raw -> qcow2");
crate::tools::qemu_img::convert_to_qcow2(runner, raw_str, qcow2_str).await?;
}
// Clean up raw file
std::fs::remove_file(&prepared.raw_path).ok();
detach_result?;
if convert {
info!(path = %prepared.qcow2_path.display(), "QCOW2 (ext4) image created");
}
Ok(()) Ok(())
} }

View file

@ -1,4 +1,4 @@
use std::path::Path; use std::path::{Path, PathBuf};
use spec_parser::schema::Target; use spec_parser::schema::Target;
use tracing::info; use tracing::info;
@ -6,35 +6,45 @@ use tracing::info;
use crate::error::ForgeError; use crate::error::ForgeError;
use crate::tools::ToolRunner; use crate::tools::ToolRunner;
/// Build a QCOW2 VM image from the staged rootfs. /// State for a prepared ZFS QCOW2 disk image, ready for Phase 1 population.
#[derive(Debug)]
pub struct PreparedZfs {
pub raw_path: PathBuf,
pub qcow2_path: PathBuf,
pub device: String,
pub pool_name: String,
pub be_dataset: String,
pub bootloader_type: String,
pub mount_dir: tempfile::TempDir,
}
impl PreparedZfs {
/// The path where the ZFS boot-environment dataset is mounted;
/// Phase 1 populates into this.
pub fn root_mount(&self) -> &Path {
self.mount_dir.path()
}
}
/// Phase 2 prepare: create raw disk, attach loopback, create ZFS pool + BE, mount.
/// ///
/// Pipeline: /// Returns a `PreparedZfs` whose `root_mount()` is the mounted BE dataset —
/// 1. Create raw disk image of specified size /// Phase 1 should populate the rootfs directly into that directory.
/// 2. Attach loopback device pub async fn prepare_zfs(
/// 3. Create ZFS pool with spec properties
/// 4. Create boot environment structure (rpool/ROOT/be-1)
/// 5. Copy staging rootfs into mounted BE
/// 6. Install bootloader via chroot
/// 7. Set bootfs property
/// 8. Export pool, detach loopback
/// 9. Convert raw -> qcow2
pub async fn build_qcow2_zfs(
target: &Target, target: &Target,
staging_root: &Path,
output_dir: &Path, output_dir: &Path,
runner: &dyn ToolRunner, runner: &dyn ToolRunner,
) -> Result<(), ForgeError> { ) -> Result<PreparedZfs, ForgeError> {
let disk_size = target let disk_size = target
.disk_size .disk_size
.as_deref() .as_deref()
.ok_or(ForgeError::MissingDiskSize)?; .ok_or(ForgeError::MissingDiskSize)?;
let bootloader_type = target.bootloader.as_deref().unwrap_or("uefi"); let bootloader_type = target.bootloader.as_deref().unwrap_or("uefi").to_string();
let raw_path = output_dir.join(format!("{}.raw", target.name)); let raw_path = output_dir.join(format!("{}.raw", target.name));
let qcow2_path = output_dir.join(format!("{}.qcow2", target.name)); let qcow2_path = output_dir.join(format!("{}.qcow2", target.name));
let raw_str = raw_path.to_str().unwrap(); let raw_str = raw_path.to_str().unwrap();
let qcow2_str = qcow2_path.to_str().unwrap();
// Collect pool properties // Collect pool properties
let pool_props: Vec<(&str, &str)> = target let pool_props: Vec<(&str, &str)> = target
@ -48,7 +58,7 @@ pub async fn build_qcow2_zfs(
}) })
.unwrap_or_default(); .unwrap_or_default();
let pool_name = "rpool"; let pool_name = "rpool".to_string();
let be_dataset = format!("{pool_name}/ROOT/be-1"); let be_dataset = format!("{pool_name}/ROOT/be-1");
info!(disk_size, "Step 1: Creating raw disk image"); info!(disk_size, "Step 1: Creating raw disk image");
@ -57,93 +67,95 @@ pub async fn build_qcow2_zfs(
info!("Step 2: Attaching loopback device"); info!("Step 2: Attaching loopback device");
let device = crate::tools::loopback::attach(runner, raw_str).await?; let device = crate::tools::loopback::attach(runner, raw_str).await?;
// Wrap the rest in a closure-like structure so we can clean up on error info!(device = %device, "Step 3: Creating ZFS pool");
let result = async { crate::tools::zpool::create(runner, &pool_name, &device, &pool_props).await?;
info!(device = %device, "Step 3: Creating ZFS pool");
crate::tools::zpool::create(runner, pool_name, &device, &pool_props).await?;
info!("Step 4: Creating boot environment structure"); info!("Step 4: Creating boot environment structure");
crate::tools::zfs::create( crate::tools::zfs::create(
runner, runner,
&format!("{pool_name}/ROOT"), &format!("{pool_name}/ROOT"),
&[("canmount", "off"), ("mountpoint", "legacy")], &[("canmount", "off"), ("mountpoint", "legacy")],
) )
.await?; .await?;
let staging_str = staging_root.to_str().unwrap_or("."); // Mount the BE dataset at a fresh tempdir — not the Phase 1 staging root.
crate::tools::zfs::create( let mount_dir = tempfile::tempdir().map_err(ForgeError::StagingSetup)?;
runner, let mount_str = mount_dir.path().to_str().unwrap();
&be_dataset,
&[("canmount", "noauto"), ("mountpoint", staging_str)],
)
.await?;
crate::tools::zfs::mount(runner, &be_dataset).await?; crate::tools::zfs::create(
runner,
&be_dataset,
&[("canmount", "noauto"), ("mountpoint", mount_str)],
)
.await?;
info!("Step 5: Copying staging rootfs into boot environment"); crate::tools::zfs::mount(runner, &be_dataset).await?;
copy_rootfs(staging_root, staging_root)?;
info!("Step 6: Installing bootloader"); info!("Step 5: ZFS BE mounted at {}", mount_str);
crate::tools::bootloader::install(runner, staging_str, pool_name, bootloader_type).await?;
info!("Step 7: Setting bootfs property"); Ok(PreparedZfs {
crate::tools::zpool::set(runner, pool_name, "bootfs", &be_dataset).await?; raw_path,
qcow2_path,
device,
pool_name,
be_dataset,
bootloader_type,
mount_dir,
})
}
info!("Step 8: Exporting ZFS pool"); /// Phase 2 finalize: install bootloader, set bootfs, unmount + export pool.
crate::tools::zfs::unmount(runner, &be_dataset).await?; pub async fn finalize_zfs(
crate::tools::zpool::export(runner, pool_name).await?; prepared: &PreparedZfs,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
let mount_str = prepared.mount_dir.path().to_str().unwrap();
Ok::<(), ForgeError>(()) info!("Finalize step 1: Installing bootloader");
crate::tools::bootloader::install(
runner,
mount_str,
&prepared.pool_name,
&prepared.bootloader_type,
)
.await?;
info!("Finalize step 2: Setting bootfs property");
crate::tools::zpool::set(runner, &prepared.pool_name, "bootfs", &prepared.be_dataset).await?;
info!("Finalize step 3: Unmounting and exporting ZFS pool");
crate::tools::zfs::unmount(runner, &prepared.be_dataset).await?;
crate::tools::zpool::export(runner, &prepared.pool_name).await?;
Ok(())
}
/// Cleanup: detach loopback, convert raw→qcow2 (if `convert` is true), remove raw file.
///
/// Always runs, even if earlier phases failed — the loopback device must be detached.
pub async fn cleanup_zfs(
prepared: PreparedZfs,
convert: bool,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
info!("Cleanup: detaching loopback device");
let detach_result = crate::tools::loopback::detach(runner, &prepared.device).await;
if convert {
let raw_str = prepared.raw_path.to_str().unwrap();
let qcow2_str = prepared.qcow2_path.to_str().unwrap();
info!("Cleanup: converting raw -> qcow2");
crate::tools::qemu_img::convert_to_qcow2(runner, raw_str, qcow2_str).await?;
} }
.await;
// Always try to detach loopback, even on error
info!("Detaching loopback device");
let detach_result = crate::tools::loopback::detach(runner, &device).await;
// Return the original error if there was one
result?;
detach_result?;
info!("Step 9: Converting raw -> qcow2");
crate::tools::qemu_img::convert_to_qcow2(runner, raw_str, qcow2_str).await?;
// Clean up raw file // Clean up raw file
std::fs::remove_file(&raw_path).ok(); std::fs::remove_file(&prepared.raw_path).ok();
info!(path = %qcow2_path.display(), "QCOW2 image created"); detach_result?;
Ok(())
}
/// Copy the staging rootfs into the mounted BE. if convert {
/// Since the BE is mounted at the staging root mountpoint, we use a recursive info!(path = %prepared.qcow2_path.display(), "QCOW2 (ZFS) image created");
/// copy approach for files that need relocation.
fn copy_rootfs(src: &Path, dest: &Path) -> Result<(), ForgeError> {
// In the actual build, the ZFS dataset is mounted at the staging_root path,
// so the files are already in place after package installation. This function
// handles the case where we need to copy from a temp staging dir into the
// mounted ZFS dataset.
if src == dest {
return Ok(());
}
for entry in walkdir::WalkDir::new(src).follow_links(false) {
let entry = entry.map_err(|e| ForgeError::Qcow2Build {
step: "copy_rootfs".to_string(),
detail: e.to_string(),
})?;
let rel = entry.path().strip_prefix(src).unwrap_or(entry.path());
let target = dest.join(rel);
if entry.path().is_dir() {
std::fs::create_dir_all(&target)?;
} else if entry.path().is_file() {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(entry.path(), &target)?;
}
} }
Ok(()) Ok(())