Skip to content

Commit 2918c34

Browse files
committed
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 <ckyrouac@redhat.com>
1 parent 9142b88 commit 2918c34

File tree

2 files changed

+206
-0
lines changed

2 files changed

+206
-0
lines changed

crates/lib/src/install.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,54 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> {
17851785
Ok(())
17861786
}
17871787

1788+
/// Check if a directory contains only mount points (or is empty), recursively.
1789+
/// Returns true if all entries in the directory tree are either:
1790+
/// - Mount points (on different filesystems)
1791+
/// - Directories that themselves contain only mount points (recursively)
1792+
/// - The lost+found directory
1793+
///
1794+
/// This handles cases like /var containing /var/lib (not a mount) which contains
1795+
/// /var/lib/containers (a mount point).
1796+
#[context("Checking if directory contains only mount points")]
1797+
fn dir_contains_only_mounts(parent_fd: &Dir, dir_name: &str) -> Result<bool> {
1798+
let Some(dir_fd) = parent_fd.open_dir_noxdev(dir_name)? else {
1799+
// The directory itself is a mount point, which is acceptable
1800+
return Ok(true);
1801+
};
1802+
1803+
for entry in dir_fd.entries()? {
1804+
let entry = DirEntryUtf8::from_cap_std(entry?);
1805+
let entry_name = entry.file_name()?;
1806+
1807+
// Skip lost+found
1808+
if entry_name == LOST_AND_FOUND {
1809+
continue;
1810+
}
1811+
1812+
let etype = entry.file_type()?;
1813+
if etype == FileType::dir() {
1814+
// If open_dir_noxdev returns None, this is a mount point on a different filesystem
1815+
if dir_fd.open_dir_noxdev(&entry_name)?.is_none() {
1816+
tracing::debug!("Found mount point: {dir_name}/{entry_name}");
1817+
continue;
1818+
}
1819+
1820+
// Not a mount point itself, but check recursively if it contains only mounts
1821+
if dir_contains_only_mounts(&dir_fd, &entry_name)? {
1822+
tracing::debug!("Directory {dir_name}/{entry_name} contains only mount points");
1823+
continue;
1824+
}
1825+
}
1826+
1827+
// Found a non-mount, non-directory-of-mounts entry
1828+
tracing::debug!("Found non-mount entry in {dir_name}: {entry_name}");
1829+
return Ok(false);
1830+
}
1831+
1832+
// All entries are mount points or directories containing only mount points
1833+
Ok(true)
1834+
}
1835+
17881836
#[context("Verifying empty rootfs")]
17891837
fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> {
17901838
for e in rootfs_fd.entries()? {
@@ -1793,6 +1841,27 @@ fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> {
17931841
if name == LOST_AND_FOUND {
17941842
continue;
17951843
}
1844+
1845+
// Check if this entry is a directory
1846+
let etype = e.file_type()?;
1847+
if etype == FileType::dir() {
1848+
// Check if this directory is a mount point (separate filesystem)
1849+
// If open_dir_noxdev returns None, it means this directory is on a different device
1850+
// (i.e., it's a mount point), which is acceptable for to-filesystem installations
1851+
if rootfs_fd.open_dir_noxdev(&name)?.is_none() {
1852+
tracing::debug!("Skipping mount point: {name}");
1853+
continue;
1854+
}
1855+
1856+
// If the directory itself is not a mount point, check if it contains only mount points.
1857+
// This handles the case where subdirectories like /var/log, /var/home are separate
1858+
// partitions, creating a /var directory that exists only to hold mount points.
1859+
if dir_contains_only_mounts(rootfs_fd, &name)? {
1860+
tracing::debug!("Skipping directory containing only mount points: {name}");
1861+
continue;
1862+
}
1863+
}
1864+
17961865
// There must be a boot directory (that is empty)
17971866
if name == BOOT {
17981867
let mut entries = rootfs_fd.read_dir(BOOT)?;

crates/tests-integration/src/install.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,143 @@ pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments)
9393
cmd!(sh, "sudo {BASE_ARGS...} -v {tmpdisk}:/disk {image} bootc install to-disk --via-loopback /disk").run()?;
9494
Ok(())
9595
}),
96+
Trial::test(
97+
"install to-filesystem with separate /var mount",
98+
move || {
99+
let sh = &xshell::Shell::new()?;
100+
reset_root(sh, image)?;
101+
102+
// Create work directory for the test
103+
let tmpd = sh.create_temp_dir()?;
104+
let work_dir = tmpd.path();
105+
106+
// Create a disk image with partitions for root and var
107+
let disk_img = work_dir.join("disk.img");
108+
let size = 12 * 1024 * 1024 * 1024;
109+
let disk_file = std::fs::File::create(&disk_img)?;
110+
disk_file.set_len(size)?;
111+
drop(disk_file);
112+
113+
// Setup loop device
114+
let loop_dev = cmd!(sh, "sudo losetup -f --show {disk_img}")
115+
.read()?
116+
.trim()
117+
.to_string();
118+
119+
// Helper closure for cleanup
120+
let cleanup = |sh: &Shell, loop_dev: &str, target: &str| {
121+
// Unmount filesystems
122+
let _ = cmd!(sh, "sudo umount -R {target}").ignore_status().run();
123+
// Deactivate LVM
124+
let _ = cmd!(sh, "sudo vgchange -an BL").ignore_status().run();
125+
let _ = cmd!(sh, "sudo vgremove -f BL").ignore_status().run();
126+
// Detach loop device
127+
let _ = cmd!(sh, "sudo losetup -d {loop_dev}").ignore_status().run();
128+
};
129+
130+
// Create partition table
131+
if let Err(e) = (|| -> Result<()> {
132+
cmd!(sh, "sudo parted -s {loop_dev} mklabel gpt").run()?;
133+
// Create BIOS boot partition (for GRUB on GPT)
134+
cmd!(sh, "sudo parted -s {loop_dev} mkpart primary 1MiB 2MiB").run()?;
135+
cmd!(sh, "sudo parted -s {loop_dev} set 1 bios_grub on").run()?;
136+
// Create EFI partition
137+
cmd!(
138+
sh,
139+
"sudo parted -s {loop_dev} mkpart primary fat32 2MiB 202MiB"
140+
)
141+
.run()?;
142+
cmd!(sh, "sudo parted -s {loop_dev} set 2 esp on").run()?;
143+
// Create boot partition
144+
cmd!(
145+
sh,
146+
"sudo parted -s {loop_dev} mkpart primary ext4 202MiB 1226MiB"
147+
)
148+
.run()?;
149+
// Create LVM partition
150+
cmd!(sh, "sudo parted -s {loop_dev} mkpart primary 1226MiB 100%").run()?;
151+
152+
// Reload partition table
153+
cmd!(sh, "sudo partprobe {loop_dev}").run()?;
154+
std::thread::sleep(std::time::Duration::from_secs(2));
155+
156+
let loop_part2 = format!("{}p2", loop_dev); // EFI
157+
let loop_part3 = format!("{}p3", loop_dev); // Boot
158+
let loop_part4 = format!("{}p4", loop_dev); // LVM
159+
160+
// Create filesystems on boot partitions
161+
cmd!(sh, "sudo mkfs.vfat -F32 {loop_part2}").run()?;
162+
cmd!(sh, "sudo mkfs.ext4 -F {loop_part3}").run()?;
163+
164+
// Setup LVM
165+
cmd!(sh, "sudo pvcreate {loop_part4}").run()?;
166+
cmd!(sh, "sudo vgcreate BL {loop_part4}").run()?;
167+
168+
// Create logical volumes
169+
cmd!(sh, "sudo lvcreate -L 4G -n var02 BL").run()?;
170+
cmd!(sh, "sudo lvcreate -L 5G -n root02 BL").run()?;
171+
172+
// Create filesystems on logical volumes
173+
cmd!(sh, "sudo mkfs.ext4 -F /dev/BL/var02").run()?;
174+
cmd!(sh, "sudo mkfs.ext4 -F /dev/BL/root02").run()?;
175+
176+
// Get UUIDs
177+
let root_uuid = cmd!(sh, "sudo blkid -s UUID -o value /dev/BL/root02")
178+
.read()?
179+
.trim()
180+
.to_string();
181+
let boot_uuid = cmd!(sh, "sudo blkid -s UUID -o value {loop_part2}")
182+
.read()?
183+
.trim()
184+
.to_string();
185+
186+
// Mount the partitions
187+
let target_dir = work_dir.join("target");
188+
std::fs::create_dir_all(&target_dir)?;
189+
let target = target_dir.to_str().unwrap();
190+
191+
cmd!(sh, "sudo mount /dev/BL/root02 {target}").run()?;
192+
cmd!(sh, "sudo mkdir -p {target}/boot").run()?;
193+
cmd!(sh, "sudo mount {loop_part3} {target}/boot").run()?;
194+
cmd!(sh, "sudo mkdir -p {target}/boot/efi").run()?;
195+
cmd!(sh, "sudo mount {loop_part2} {target}/boot/efi").run()?;
196+
197+
// Critical: Mount /var as a separate partition
198+
cmd!(sh, "sudo mkdir -p {target}/var").run()?;
199+
cmd!(sh, "sudo mount /dev/BL/var02 {target}/var").run()?;
200+
201+
// Run bootc install to-filesystem
202+
// This should succeed and handle the separate /var mount correctly
203+
// Mount the target at /target inside the container for simplicity
204+
cmd!(
205+
sh,
206+
"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"
207+
)
208+
.run()?;
209+
210+
// Verify the installation succeeded
211+
// Check that bootc created the necessary files
212+
cmd!(sh, "sudo test -d {target}/ostree").run()?;
213+
cmd!(sh, "sudo test -d {target}/ostree/repo").run()?;
214+
// Verify bootloader was installed
215+
cmd!(sh, "sudo test -d {target}/boot/grub2").run()?;
216+
217+
Ok(())
218+
})() {
219+
let target = work_dir.join("target");
220+
let target_str = target.to_str().unwrap();
221+
cleanup(sh, &loop_dev, target_str);
222+
return Err(e.into());
223+
}
224+
225+
// Clean up on success
226+
let target = work_dir.join("target");
227+
let target_str = target.to_str().unwrap();
228+
cleanup(sh, &loop_dev, target_str);
229+
230+
Ok(())
231+
},
232+
),
96233
Trial::test(
97234
"replace=alongside with ssh keys and a karg, and SELinux disabled",
98235
move || {

0 commit comments

Comments
 (0)