From 510589a2c96f3909719dcb4218e9126046be0ceb Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Wed, 19 Nov 2025 13:09:47 -0500 Subject: [PATCH] install: Allow mounted directories during install to-filesystem When performing a to-filesystem installation, the target directory may contain pre-existing mount points for directories like /var, /var/lib/containers, etc. These are legitimate in hybrid/existing filesystem scenarios where certain directories are on separate partitions. This change enhances the empty rootdir check to: - Recursively detect directories that contain only mount points - Skip directories that are themselves mount points - Allow installation to proceed when mount hierarchies exist (e.g., /var containing /var/lib which contains mounted /var/lib/containers) Also adds integration test coverage for separate /var mount scenarios using LVM. Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: ckyrouac --- crates/lib/src/install.rs | 69 ++++++++++++ crates/tests-integration/src/install.rs | 137 ++++++++++++++++++++++++ 2 files changed, 206 insertions(+) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 12e65c599..4377a2417 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1803,6 +1803,54 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { Ok(()) } +/// Check if a directory contains only mount points (or is empty), recursively. +/// Returns true if all entries in the directory tree are either: +/// - Mount points (on different filesystems) +/// - Directories that themselves contain only mount points (recursively) +/// - The lost+found directory +/// +/// This handles cases like /var containing /var/lib (not a mount) which contains +/// /var/lib/containers (a mount point). +#[context("Checking if directory contains only mount points")] +fn dir_contains_only_mounts(parent_fd: &Dir, dir_name: &str) -> Result { + let Some(dir_fd) = parent_fd.open_dir_noxdev(dir_name)? else { + // The directory itself is a mount point, which is acceptable + return Ok(true); + }; + + for entry in dir_fd.entries()? { + let entry = DirEntryUtf8::from_cap_std(entry?); + let entry_name = entry.file_name()?; + + // Skip lost+found + if entry_name == LOST_AND_FOUND { + continue; + } + + let etype = entry.file_type()?; + if etype == FileType::dir() { + // If open_dir_noxdev returns None, this is a mount point on a different filesystem + if dir_fd.open_dir_noxdev(&entry_name)?.is_none() { + tracing::debug!("Found mount point: {dir_name}/{entry_name}"); + continue; + } + + // Not a mount point itself, but check recursively if it contains only mounts + if dir_contains_only_mounts(&dir_fd, &entry_name)? { + tracing::debug!("Directory {dir_name}/{entry_name} contains only mount points"); + continue; + } + } + + // Found a non-mount, non-directory-of-mounts entry + tracing::debug!("Found non-mount entry in {dir_name}: {entry_name}"); + return Ok(false); + } + + // All entries are mount points or directories containing only mount points + Ok(true) +} + #[context("Verifying empty rootfs")] fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> { for e in rootfs_fd.entries()? { @@ -1811,6 +1859,27 @@ fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> { if name == LOST_AND_FOUND { continue; } + + // Check if this entry is a directory + let etype = e.file_type()?; + if etype == FileType::dir() { + // Check if this directory is a mount point (separate filesystem) + // If open_dir_noxdev returns None, it means this directory is on a different device + // (i.e., it's a mount point), which is acceptable for to-filesystem installations + if rootfs_fd.open_dir_noxdev(&name)?.is_none() { + tracing::debug!("Skipping mount point: {name}"); + continue; + } + + // If the directory itself is not a mount point, check if it contains only mount points. + // This handles the case where subdirectories like /var/log, /var/home are separate + // partitions, creating a /var directory that exists only to hold mount points. + if dir_contains_only_mounts(rootfs_fd, &name)? { + tracing::debug!("Skipping directory containing only mount points: {name}"); + continue; + } + } + // There must be a boot directory (that is empty) if name == BOOT { let mut entries = rootfs_fd.read_dir(BOOT)?; diff --git a/crates/tests-integration/src/install.rs b/crates/tests-integration/src/install.rs index aeef12c8d..8487c0354 100644 --- a/crates/tests-integration/src/install.rs +++ b/crates/tests-integration/src/install.rs @@ -93,6 +93,143 @@ pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments) cmd!(sh, "sudo {BASE_ARGS...} -v {tmpdisk}:/disk {image} bootc install to-disk --via-loopback /disk").run()?; Ok(()) }), + Trial::test( + "install to-filesystem with separate /var mount", + move || { + let sh = &xshell::Shell::new()?; + reset_root(sh, image)?; + + // Create work directory for the test + let tmpd = sh.create_temp_dir()?; + let work_dir = tmpd.path(); + + // Create a disk image with partitions for root and var + let disk_img = work_dir.join("disk.img"); + let size = 12 * 1024 * 1024 * 1024; + let disk_file = std::fs::File::create(&disk_img)?; + disk_file.set_len(size)?; + drop(disk_file); + + // Setup loop device + let loop_dev = cmd!(sh, "sudo losetup -f --show {disk_img}") + .read()? + .trim() + .to_string(); + + // Helper closure for cleanup + let cleanup = |sh: &Shell, loop_dev: &str, target: &str| { + // Unmount filesystems + let _ = cmd!(sh, "sudo umount -R {target}").ignore_status().run(); + // Deactivate LVM + let _ = cmd!(sh, "sudo vgchange -an BL").ignore_status().run(); + let _ = cmd!(sh, "sudo vgremove -f BL").ignore_status().run(); + // Detach loop device + let _ = cmd!(sh, "sudo losetup -d {loop_dev}").ignore_status().run(); + }; + + // Create partition table + if let Err(e) = (|| -> Result<()> { + cmd!(sh, "sudo parted -s {loop_dev} mklabel gpt").run()?; + // Create BIOS boot partition (for GRUB on GPT) + cmd!(sh, "sudo parted -s {loop_dev} mkpart primary 1MiB 2MiB").run()?; + cmd!(sh, "sudo parted -s {loop_dev} set 1 bios_grub on").run()?; + // Create EFI partition + cmd!( + sh, + "sudo parted -s {loop_dev} mkpart primary fat32 2MiB 202MiB" + ) + .run()?; + cmd!(sh, "sudo parted -s {loop_dev} set 2 esp on").run()?; + // Create boot partition + cmd!( + sh, + "sudo parted -s {loop_dev} mkpart primary ext4 202MiB 1226MiB" + ) + .run()?; + // Create LVM partition + cmd!(sh, "sudo parted -s {loop_dev} mkpart primary 1226MiB 100%").run()?; + + // Reload partition table + cmd!(sh, "sudo partprobe {loop_dev}").run()?; + std::thread::sleep(std::time::Duration::from_secs(2)); + + let loop_part2 = format!("{}p2", loop_dev); // EFI + let loop_part3 = format!("{}p3", loop_dev); // Boot + let loop_part4 = format!("{}p4", loop_dev); // LVM + + // Create filesystems on boot partitions + cmd!(sh, "sudo mkfs.vfat -F32 {loop_part2}").run()?; + cmd!(sh, "sudo mkfs.ext4 -F {loop_part3}").run()?; + + // Setup LVM + cmd!(sh, "sudo pvcreate {loop_part4}").run()?; + cmd!(sh, "sudo vgcreate BL {loop_part4}").run()?; + + // Create logical volumes + cmd!(sh, "sudo lvcreate -L 4G -n var02 BL").run()?; + cmd!(sh, "sudo lvcreate -L 5G -n root02 BL").run()?; + + // Create filesystems on logical volumes + cmd!(sh, "sudo mkfs.ext4 -F /dev/BL/var02").run()?; + cmd!(sh, "sudo mkfs.ext4 -F /dev/BL/root02").run()?; + + // Get UUIDs + let root_uuid = cmd!(sh, "sudo blkid -s UUID -o value /dev/BL/root02") + .read()? + .trim() + .to_string(); + let boot_uuid = cmd!(sh, "sudo blkid -s UUID -o value {loop_part2}") + .read()? + .trim() + .to_string(); + + // Mount the partitions + let target_dir = work_dir.join("target"); + std::fs::create_dir_all(&target_dir)?; + let target = target_dir.to_str().unwrap(); + + cmd!(sh, "sudo mount /dev/BL/root02 {target}").run()?; + cmd!(sh, "sudo mkdir -p {target}/boot").run()?; + cmd!(sh, "sudo mount {loop_part3} {target}/boot").run()?; + cmd!(sh, "sudo mkdir -p {target}/boot/efi").run()?; + cmd!(sh, "sudo mount {loop_part2} {target}/boot/efi").run()?; + + // Critical: Mount /var as a separate partition + cmd!(sh, "sudo mkdir -p {target}/var").run()?; + cmd!(sh, "sudo mount /dev/BL/var02 {target}/var").run()?; + + // Run bootc install to-filesystem + // This should succeed and handle the separate /var mount correctly + // Mount the target at /target inside the container for simplicity + cmd!( + sh, + "sudo {BASE_ARGS...} -v {target}:/target -v /dev:/dev {image} bootc install to-filesystem --karg=root=UUID={root_uuid} --root-mount-spec=UUID={root_uuid} --boot-mount-spec=UUID={boot_uuid} /target" + ) + .run()?; + + // Verify the installation succeeded + // Check that bootc created the necessary files + cmd!(sh, "sudo test -d {target}/ostree").run()?; + cmd!(sh, "sudo test -d {target}/ostree/repo").run()?; + // Verify bootloader was installed + cmd!(sh, "sudo test -d {target}/boot/grub2").run()?; + + Ok(()) + })() { + let target = work_dir.join("target"); + let target_str = target.to_str().unwrap(); + cleanup(sh, &loop_dev, target_str); + return Err(e.into()); + } + + // Clean up on success + let target = work_dir.join("target"); + let target_str = target.to_str().unwrap(); + cleanup(sh, &loop_dev, target_str); + + Ok(()) + }, + ), Trial::test( "replace=alongside with ssh keys and a karg, and SELinux disabled", move || {