diff --git a/crates/forge-engine/src/lib.rs b/crates/forge-engine/src/lib.rs index db7b9da..7d1762d 100644 --- a/crates/forge-engine/src/lib.rs +++ b/crates/forge-engine/src/lib.rs @@ -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, ForgeError> { match target_name { Some(name) => { diff --git a/crates/forge-engine/src/phase1/mod.rs b/crates/forge-engine/src/phase1/mod.rs index bb46369..05ef99c 100644 --- a/crates/forge-engine/src/phase1/mod.rs +++ b/crates/forge-engine/src/phase1/mod.rs @@ -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 { - 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, diff --git a/crates/forge-engine/src/phase2/mod.rs b/crates/forge-engine/src/phase2/mod.rs index 63a72ab..0e6e03d 100644 --- a/crates/forge-engine/src/phase2/mod.rs +++ b/crates/forge-engine/src/phase2/mod.rs @@ -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(()) +} diff --git a/crates/forge-engine/src/phase2/qcow2.rs b/crates/forge-engine/src/phase2/qcow2.rs index d4903e9..22bdecd 100644 --- a/crates/forge-engine/src/phase2/qcow2.rs +++ b/crates/forge-engine/src/phase2/qcow2.rs @@ -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 { 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()); diff --git a/crates/forge-engine/src/phase2/qcow2_ext4.rs b/crates/forge-engine/src/phase2/qcow2_ext4.rs index 89cf8ca..c25c2dc 100644 --- a/crates/forge-engine/src/phase2/qcow2_ext4.rs +++ b/crates/forge-engine/src/phase2/qcow2_ext4.rs @@ -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 { 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(()) } diff --git a/crates/forge-engine/src/phase2/qcow2_zfs.rs b/crates/forge-engine/src/phase2/qcow2_zfs.rs index 0a21108..2fede02 100644 --- a/crates/forge-engine/src/phase2/qcow2_zfs.rs +++ b/crates/forge-engine/src/phase2/qcow2_zfs.rs @@ -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 { 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(())