refraction-forger/crates/forge-engine/src/phase2/qcow2.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

134 lines
4.1 KiB
Rust

use std::path::Path;
use spec_parser::schema::Target;
use crate::error::ForgeError;
use crate::tools::ToolRunner;
/// 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.
///
/// Dispatches to ext4 or ZFS based on `target.filesystem`.
pub async fn prepare_qcow2(
target: &Target,
output_dir: &Path,
runner: &dyn ToolRunner,
) -> Result<PreparedQcow2, ForgeError> {
match target.filesystem.as_deref().unwrap_or("zfs") {
"zfs" => {
let prepared = super::qcow2_zfs::prepare_zfs(target, output_dir, runner).await?;
Ok(PreparedQcow2::Zfs(prepared))
}
"ext4" => {
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(),
target: target.name.clone(),
}),
}
}
/// 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::*;
use spec_parser::schema::{Target, TargetKind};
fn make_target(fs: Option<&str>) -> Target {
Target {
name: "test".to_string(),
kind: TargetKind::Qcow2,
disk_size: Some("2G".to_string()),
bootloader: Some("uefi".to_string()),
filesystem: fs.map(|s| s.to_string()),
push_to: None,
entrypoint: None,
environment: None,
pool: None,
}
}
#[test]
fn test_unsupported_filesystem_error() {
let target = make_target(Some("btrfs"));
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
let tmpdir = tempfile::tempdir().unwrap();
use crate::tools::{ToolOutput, ToolRunner};
use std::future::Future;
use std::pin::Pin;
struct FailRunner;
impl ToolRunner for FailRunner {
fn run<'a>(
&'a self,
_program: &'a str,
_args: &'a [&'a str],
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ForgeError>> + Send + 'a>>
{
Box::pin(async {
Err(ForgeError::Qcow2Build {
step: "test".to_string(),
detail: "not expected to be called".to_string(),
})
})
}
}
prepare_qcow2(&target, tmpdir.path(), &FailRunner).await
});
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ForgeError::UnsupportedFilesystem { .. }));
}
#[test]
fn test_default_filesystem_is_zfs() {
let target = make_target(None);
assert_eq!(target.filesystem.as_deref().unwrap_or("zfs"), "zfs");
}
}