Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could have a unit test for this for the trivial cases at least (no inner mounts). Actually testing the mount case needs an integration test.

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}");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logs here feel a bit noisy potentially, how about making this

fn require_dir_contains_only_mounts(...) -> Result<()> and the error message is the first non-mount entry?

Then I think we probably don't need the logs at all, the error will include the info.

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()? {
Expand All @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offhand this seems sane to me

// 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;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels a bit weird to me to fall through here on error instead of just erroring out, see above

}

// There must be a boot directory (that is empty)
if name == BOOT {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might be able to consolidate this logic with the above?

let mut entries = rootfs_fd.read_dir(BOOT)?;
Expand Down
137 changes: 137 additions & 0 deletions crates/tests-integration/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {
Expand Down