mirror of
https://github.com/CloudNebulaProject/refraction-forger.git
synced 2026-04-10 13:20:40 +00:00
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:
parent
d24dcc0363
commit
7d97061e0f
6 changed files with 417 additions and 310 deletions
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue