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 {
info!(target = %target.name, kind = %target.kind, "Building target");
// Phase 1: Assemble rootfs
let phase1_result =
phase1::execute(self.spec, self.files_dir, self.runner).await?;
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?;
// Phase 2: Produce target artifact
phase2::execute(
target,
&phase1_result.staging_root,
self.files_dir,
self.output_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");
}
@ -55,6 +60,40 @@ impl<'a> BuildContext<'a> {
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) => {

View file

@ -21,45 +21,57 @@ pub struct Phase1Result {
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,
/// then applies common customizations and overlays.
/// For OCI/Artifact targets that manage their own staging directory.
/// Delegates to `execute_into()` after creating the tempdir.
pub async fn execute(
spec: &ImageSpec,
files_dir: &Path,
runner: &dyn ToolRunner,
) -> 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 root = staging_root.to_str().unwrap();
info!(root, "Staging directory created");
// 2. Extract base tarball
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");
execute_into(spec, files_dir, runner, &staging_root).await?;
Ok(Phase1Result {
staging_root,

View file

@ -10,18 +10,16 @@ use spec_parser::schema::{Target, TargetKind};
use tracing::info;
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,
/// the QCOW2 file is automatically pushed as an OCI artifact.
/// QCOW2 targets are handled directly by the orchestrator in `lib.rs` via
/// the prepare/finalize/cleanup flow.
pub async fn execute(
target: &Target,
staging_root: &Path,
files_dir: &Path,
output_dir: &Path,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
info!(
target = %target.name,
@ -33,50 +31,55 @@ pub async fn execute(
TargetKind::Oci => {
oci::build_oci(target, staging_root, output_dir)?;
}
TargetKind::Qcow2 => {
qcow2::build_qcow2(target, staging_root, output_dir, runner).await?;
}
TargetKind::Artifact => {
artifact::build_artifact(target, staging_root, output_dir, files_dir)?;
}
}
// 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(),
})?;
TargetKind::Qcow2 => {
unreachable!("QCOW2 targets are handled by the orchestrator via prepare/finalize/cleanup");
}
}
info!(target = %target.name, "Phase 2 complete");
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::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
/// - `"ext4"`: GPT+EFI+ext4 with GRUB bootloader
pub async fn build_qcow2(
/// Dispatches to ext4 or ZFS based on `target.filesystem`.
pub async fn prepare_qcow2(
target: &Target,
staging_root: &Path,
output_dir: &Path,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
) -> Result<PreparedQcow2, ForgeError> {
match target.filesystem.as_deref().unwrap_or("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" => {
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 {
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)]
mod tests {
use super::*;
@ -53,12 +95,8 @@ mod tests {
let target = make_target(Some("btrfs"));
let rt = tokio::runtime::Runtime::new().unwrap();
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 staging = tempfile::tempdir().unwrap();
// Create a mock runner that always succeeds
use crate::tools::{ToolOutput, ToolRunner};
use std::future::Future;
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());

View file

@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use spec_parser::schema::Target;
use tracing::info;
@ -6,26 +6,33 @@ use tracing::info;
use crate::error::ForgeError;
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:
/// 1. Create raw disk image of specified size
/// 2. Attach loopback device + partprobe
/// 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(
/// Returns a `PreparedExt4` whose `root_mount()` is the mounted ext4 root —
/// Phase 1 should populate the rootfs directly into that directory.
pub async fn prepare_ext4(
target: &Target,
staging_root: &Path,
output_dir: &Path,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
) -> Result<PreparedExt4, ForgeError> {
let disk_size = target
.disk_size
.as_deref()
@ -34,127 +41,123 @@ pub async fn build_qcow2_ext4(
let raw_path = output_dir.join(format!("{}.raw", target.name));
let qcow2_path = output_dir.join(format!("{}.qcow2", target.name));
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");
crate::tools::qemu_img::create_raw(runner, raw_str, disk_size).await?;
info!("Step 2: Attaching loopback device");
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 result = async {
info!(device = %device, "Step 3: Creating GPT partition table");
let (efi_part, root_part) =
crate::tools::partition::create_gpt_efi_root(runner, &device).await?;
info!(device = %device, "Step 3: Creating GPT partition table");
let (efi_part, root_part) =
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");
crate::tools::partition::mkfs_fat32(runner, &efi_part).await?;
crate::tools::partition::mkfs_ext4(runner, &root_part).await?;
info!("Step 4: Formatting partitions");
crate::tools::partition::mkfs_fat32(runner, &efi_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_str = mount_dir.path().to_str().unwrap();
let mount_dir = tempfile::tempdir().map_err(ForgeError::StagingSetup)?;
let mount_str = mount_dir.path().to_str().unwrap();
info!("Step 5: Mounting root partition and copying rootfs");
crate::tools::partition::mount(runner, &root_part, mount_str).await?;
info!("Step 5: Mounting root partition at {}", mount_str);
crate::tools::partition::mount(runner, &root_part, mount_str).await?;
// Copy staging rootfs into mounted root
copy_rootfs(staging_root, mount_dir.path(), runner).await?;
info!("Step 6: Mounting EFI partition");
let efi_mount = 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, &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(())
Ok(PreparedExt4 {
raw_path,
qcow2_path,
device,
efi_part,
root_part,
mount_dir,
})
}
/// Copy the staging rootfs into the mounted root partition.
///
/// Uses `cp -a` (archive mode) to properly preserve symlinks, permissions,
/// 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,
/// Phase 2 finalize: mount EFI, bind-mount pseudofs, install GRUB, unmount everything.
pub async fn finalize_ext4(
prepared: &PreparedExt4,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
let src_str = format!("{}/.", src.display());
let dest_str = dest.to_str().unwrap();
let mount_str = prepared.mount_dir.path().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
.run("cp", &["-a", &src_str, dest_str])
.await
.map_err(|_| ForgeError::Qcow2Build {
step: "copy_rootfs".to_string(),
detail: format!("cp -a {}/. -> {}", src.display(), dest.display()),
})?;
.run(
"chroot",
&[
mount_str,
"/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(())
}

View file

@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use spec_parser::schema::Target;
use tracing::info;
@ -6,35 +6,45 @@ use tracing::info;
use crate::error::ForgeError;
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:
/// 1. Create raw disk image of specified size
/// 2. Attach loopback device
/// 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(
/// Returns a `PreparedZfs` whose `root_mount()` is the mounted BE dataset —
/// Phase 1 should populate the rootfs directly into that directory.
pub async fn prepare_zfs(
target: &Target,
staging_root: &Path,
output_dir: &Path,
runner: &dyn ToolRunner,
) -> Result<(), ForgeError> {
) -> Result<PreparedZfs, ForgeError> {
let disk_size = target
.disk_size
.as_deref()
.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 qcow2_path = output_dir.join(format!("{}.qcow2", target.name));
let raw_str = raw_path.to_str().unwrap();
let qcow2_str = qcow2_path.to_str().unwrap();
// Collect pool properties
let pool_props: Vec<(&str, &str)> = target
@ -48,7 +58,7 @@ pub async fn build_qcow2_zfs(
})
.unwrap_or_default();
let pool_name = "rpool";
let pool_name = "rpool".to_string();
let be_dataset = format!("{pool_name}/ROOT/be-1");
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");
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
let result = async {
info!(device = %device, "Step 3: Creating ZFS pool");
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");
crate::tools::zfs::create(
runner,
&format!("{pool_name}/ROOT"),
&[("canmount", "off"), ("mountpoint", "legacy")],
)
.await?;
info!("Step 4: Creating boot environment structure");
crate::tools::zfs::create(
runner,
&format!("{pool_name}/ROOT"),
&[("canmount", "off"), ("mountpoint", "legacy")],
)
.await?;
let staging_str = staging_root.to_str().unwrap_or(".");
crate::tools::zfs::create(
runner,
&be_dataset,
&[("canmount", "noauto"), ("mountpoint", staging_str)],
)
.await?;
// Mount the BE dataset at a fresh tempdir — not the Phase 1 staging root.
let mount_dir = tempfile::tempdir().map_err(ForgeError::StagingSetup)?;
let mount_str = mount_dir.path().to_str().unwrap();
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");
copy_rootfs(staging_root, staging_root)?;
crate::tools::zfs::mount(runner, &be_dataset).await?;
info!("Step 6: Installing bootloader");
crate::tools::bootloader::install(runner, staging_str, pool_name, bootloader_type).await?;
info!("Step 5: ZFS BE mounted at {}", mount_str);
info!("Step 7: Setting bootfs property");
crate::tools::zpool::set(runner, pool_name, "bootfs", &be_dataset).await?;
Ok(PreparedZfs {
raw_path,
qcow2_path,
device,
pool_name,
be_dataset,
bootloader_type,
mount_dir,
})
}
info!("Step 8: Exporting ZFS pool");
crate::tools::zfs::unmount(runner, &be_dataset).await?;
crate::tools::zpool::export(runner, pool_name).await?;
/// Phase 2 finalize: install bootloader, set bootfs, unmount + export pool.
pub async fn finalize_zfs(
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
std::fs::remove_file(&raw_path).ok();
std::fs::remove_file(&prepared.raw_path).ok();
info!(path = %qcow2_path.display(), "QCOW2 image created");
Ok(())
}
detach_result?;
/// Copy the staging rootfs into the mounted BE.
/// Since the BE is mounted at the staging root mountpoint, we use a recursive
/// 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)?;
}
if convert {
info!(path = %prepared.qcow2_path.display(), "QCOW2 (ZFS) image created");
}
Ok(())