Skip to content

Commit 6802697

Browse files
committed
install: Enable installing to multi device parents
When the root filesystem spans multiple backing devices (e.g., LVM across multiple disks), discover all parent devices and find ESP partitions on each. For bootupd/GRUB, install the bootloader to all devices with an ESP partition, enabling boot from any disk in a multi-disk setup. systemd-boot and zipl only support single-device configurations. This adds a new integration test validating both single-ESP and dual-ESP multi-device scenarios. Fixes: #481 Assisted-by: Claude Code (Opus 4.5) Signed-off-by: ckyrouac <ckyrouac@redhat.com>
1 parent b901498 commit 6802697

File tree

7 files changed

+417
-44
lines changed

7 files changed

+417
-44
lines changed

crates/lib/src/bootc_composefs/boot.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -520,8 +520,23 @@ pub(crate) fn setup_composefs_bls_boot(
520520

521521
cmdline_options.extend(&Cmdline::from(&composefs_cmdline));
522522

523-
// Locate ESP partition device
524-
let esp_part = esp_in(&root_setup.device_info)?;
523+
// Find all ESP partitions across all backing devices.
524+
// For composefs with systemd-boot, we only support a single ESP.
525+
let mut esp_parts: Vec<&bootc_blockdev::Partition> = Vec::new();
526+
for device in &root_setup.device_info {
527+
if let Some(esp) = device.find_partition_of_esp()? {
528+
esp_parts.push(esp);
529+
}
530+
}
531+
let esp_part = match esp_parts.len() {
532+
0 => {
533+
anyhow::bail!("Cannot locate ESP: no ESP partition found on any backing device")
534+
}
535+
1 => esp_parts.into_iter().next().unwrap(),
536+
n => anyhow::bail!(
537+
"Found {n} ESP partitions across backing devices; only a single ESP is supported for composefs boot"
538+
),
539+
};
525540

526541
(
527542
root_setup.physical_root_path.clone(),
@@ -1063,7 +1078,12 @@ pub(crate) fn setup_composefs_uki_boot(
10631078
BootSetupType::Setup((root_setup, state, postfetch, ..)) => {
10641079
state.require_no_kargs_for_uki()?;
10651080

1066-
let esp_part = esp_in(&root_setup.device_info)?;
1081+
//TODO: Handle multiple devices (RAID, LVM, etc)
1082+
let device_info = root_setup
1083+
.device_info
1084+
.first()
1085+
.ok_or_else(|| anyhow!("Cannot locate ESP: no backing device found"))?;
1086+
let esp_part = esp_in(device_info)?;
10671087

10681088
(
10691089
root_setup.physical_root_path.clone(),
@@ -1233,7 +1253,8 @@ pub(crate) async fn setup_composefs_boot(
12331253

12341254
if cfg!(target_arch = "s390x") {
12351255
// TODO: Integrate s390x support into install_via_bootupd
1236-
crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?;
1256+
// zipl only supports single device
1257+
crate::bootloader::install_via_zipl(root_setup.device_info.first(), boot_uuid)?;
12371258
} else if postfetch.detected_bootloader == Bootloader::Grub {
12381259
crate::bootloader::install_via_bootupd(
12391260
&root_setup.device_info,
@@ -1242,8 +1263,9 @@ pub(crate) async fn setup_composefs_boot(
12421263
None,
12431264
)?;
12441265
} else {
1266+
// systemd-boot only supports single device
12451267
crate::bootloader::install_systemd_boot(
1246-
&root_setup.device_info,
1268+
root_setup.device_info.first(),
12471269
&root_setup.physical_root_path,
12481270
&state.config_opts,
12491271
None,

crates/lib/src/bootloader.rs

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result<bool> {
8282

8383
#[context("Installing bootloader")]
8484
pub(crate) fn install_via_bootupd(
85-
device: &PartitionTable,
85+
devices: &[PartitionTable],
8686
rootfs: &Utf8Path,
8787
configopts: &crate::install::InstallConfigOpts,
8888
deployment_path: Option<&str>,
@@ -97,26 +97,61 @@ pub(crate) fn install_via_bootupd(
9797
} else {
9898
vec![]
9999
};
100-
let devpath = device.path();
101-
println!("Installing bootloader via bootupd");
102-
Command::new("bootupctl")
103-
.args(["backend", "install", "--write-uuid"])
104-
.args(verbose)
105-
.args(bootupd_opts.iter().copied().flatten())
106-
.args(src_root_arg)
107-
.args(["--device", devpath.as_str(), rootfs.as_str()])
108-
.log_debug()
109-
.run_inherited_with_cmd_context()
100+
101+
// No backing devices with ESP found. Run bootupd without --device and let it
102+
// try to auto-detect. This works for:
103+
// - BIOS boot (uses MBR, not ESP)
104+
// - Systems where bootupd can find ESP via mounted /boot/efi
105+
// UEFI boot will fail if bootupd cannot locate the ESP.
106+
if devices.is_empty() {
107+
tracing::warn!(
108+
"No backing device with ESP found; UEFI boot may fail if ESP cannot be auto-detected"
109+
);
110+
println!("Installing bootloader via bootupd (no target device specified)");
111+
return Command::new("bootupctl")
112+
.args(["backend", "install", "--write-uuid"])
113+
.args(verbose)
114+
.args(bootupd_opts.iter().copied().flatten())
115+
.args(&src_root_arg)
116+
.arg(rootfs.as_str())
117+
.log_debug()
118+
.run_inherited_with_cmd_context();
119+
}
120+
121+
// Install bootloader to each device
122+
for dev in devices {
123+
let devpath = dev.path();
124+
println!("Installing bootloader via bootupd to {devpath}");
125+
Command::new("bootupctl")
126+
.args(["backend", "install", "--write-uuid"])
127+
.args(verbose)
128+
.args(bootupd_opts.iter().copied().flatten())
129+
.args(&src_root_arg)
130+
.args(["--device", devpath.as_str()])
131+
.arg(rootfs.as_str())
132+
.log_debug()
133+
.run_inherited_with_cmd_context()?;
134+
}
135+
136+
Ok(())
110137
}
111138

112139
#[context("Installing bootloader")]
113140
pub(crate) fn install_systemd_boot(
114-
device: &PartitionTable,
141+
device: Option<&PartitionTable>,
115142
_rootfs: &Utf8Path,
116143
_configopts: &crate::install::InstallConfigOpts,
117144
_deployment_path: Option<&str>,
118145
autoenroll: Option<SecurebootKeys>,
119146
) -> Result<()> {
147+
// systemd-boot requires the backing device to locate the ESP partition
148+
let device = device.ok_or_else(|| {
149+
anyhow!(
150+
"Cannot install systemd-boot: no single backing device found \
151+
(root may span multiple devices such as LVM across multiple disks)"
152+
)
153+
})?;
154+
120155
let esp_part = device
121156
.find_partition_of_type(discoverable_partition_specification::ESP)
122157
.ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?;
@@ -161,7 +196,15 @@ pub(crate) fn install_systemd_boot(
161196
}
162197

163198
#[context("Installing bootloader using zipl")]
164-
pub(crate) fn install_via_zipl(device: &PartitionTable, boot_uuid: &str) -> Result<()> {
199+
pub(crate) fn install_via_zipl(device: Option<&PartitionTable>, boot_uuid: &str) -> Result<()> {
200+
// zipl requires the backing device information to install the bootloader
201+
let device = device.ok_or_else(|| {
202+
anyhow!(
203+
"Cannot install zipl bootloader: no single backing device found \
204+
(root may span multiple devices such as LVM across multiple disks)"
205+
)
206+
})?;
207+
165208
// Identify the target boot partition from UUID
166209
let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
167210
let boot_dir = Utf8Path::new(&fs.target);

crates/lib/src/install.rs

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,10 @@ pub(crate) fn exec_in_host_mountns(args: &[std::ffi::OsString]) -> Result<()> {
11411141
pub(crate) struct RootSetup {
11421142
#[cfg(feature = "install-to-disk")]
11431143
luks_device: Option<String>,
1144-
pub(crate) device_info: bootc_blockdev::PartitionTable,
1144+
/// Information about the backing block device partition tables.
1145+
/// Contains all devices that have an ESP partition when the root filesystem
1146+
/// spans multiple backing devices (e.g., LVM across multiple disks).
1147+
pub(crate) device_info: Vec<bootc_blockdev::PartitionTable>,
11451148
/// Absolute path to the location where we've mounted the physical
11461149
/// root filesystem for the system we're installing.
11471150
pub(crate) physical_root_path: Utf8PathBuf,
@@ -1602,7 +1605,9 @@ async fn install_with_sysroot(
16021605

16031606
if cfg!(target_arch = "s390x") {
16041607
// TODO: Integrate s390x support into install_via_bootupd
1605-
crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?;
1608+
// zipl only supports single device
1609+
let device = rootfs.device_info.first();
1610+
crate::bootloader::install_via_zipl(device, boot_uuid)?;
16061611
} else {
16071612
match postfetch.detected_bootloader {
16081613
Bootloader::Grub => {
@@ -1733,15 +1738,21 @@ async fn install_to_filesystem_impl(
17331738
// Drop exclusive ownership since we're done with mutation
17341739
let rootfs = &*rootfs;
17351740

1736-
match &rootfs.device_info.label {
1737-
bootc_blockdev::PartitionType::Dos => crate::utils::medium_visibility_warning(
1738-
"Installing to `dos` format partitions is not recommended",
1739-
),
1740-
bootc_blockdev::PartitionType::Gpt => {
1741-
// The only thing we should be using in general
1742-
}
1743-
bootc_blockdev::PartitionType::Unknown(o) => {
1744-
crate::utils::medium_visibility_warning(&format!("Unknown partition label {o}"))
1741+
// Check partition type of all backing devices
1742+
for device_info in &rootfs.device_info {
1743+
match &device_info.label {
1744+
bootc_blockdev::PartitionType::Dos => {
1745+
crate::utils::medium_visibility_warning(&format!(
1746+
"Installing to `dos` format partitions is not recommended: {}",
1747+
device_info.path()
1748+
))
1749+
}
1750+
bootc_blockdev::PartitionType::Gpt => {
1751+
// The only thing we should be using in general
1752+
}
1753+
bootc_blockdev::PartitionType::Unknown(o) => crate::utils::medium_visibility_warning(
1754+
&format!("Unknown partition label {o}: {}", device_info.path()),
1755+
),
17451756
}
17461757
}
17471758

@@ -2291,27 +2302,69 @@ pub(crate) async fn install_to_filesystem(
22912302
};
22922303
tracing::debug!("boot UUID: {boot_uuid:?}");
22932304

2294-
// Find the real underlying backing device for the root. This is currently just required
2295-
// for GRUB (BIOS) and in the future zipl (I think).
2296-
let backing_device = {
2305+
// Walk up the block device hierarchy to find physical backing device(s).
2306+
// Examples:
2307+
// /dev/sda3 -> /dev/sda (single disk)
2308+
// /dev/mapper/vg-lv -> /dev/sda2, /dev/sdb2 (LVM across two disks)
2309+
let backing_devices: Vec<String> = {
22972310
let mut dev = inspect.source;
22982311
loop {
22992312
tracing::debug!("Finding parents for {dev}");
2300-
let mut parents = bootc_blockdev::find_parent_devices(&dev)?.into_iter();
2301-
let Some(parent) = parents.next() else {
2302-
break;
2303-
};
2304-
if let Some(next) = parents.next() {
2305-
anyhow::bail!(
2306-
"Found multiple parent devices {parent} and {next}; not currently supported"
2313+
let parents = bootc_blockdev::find_parent_devices(&dev)?;
2314+
if parents.is_empty() {
2315+
// Reached a physical disk
2316+
break vec![dev];
2317+
}
2318+
if parents.len() > 1 {
2319+
// Multi-device (e.g., LVM across disks) - return all
2320+
tracing::debug!(
2321+
"Found multiple parent devices: {:?}; will search for ESP",
2322+
parents
23072323
);
2324+
break parents;
2325+
}
2326+
// Single parent (e.g. LVM LV -> VG -> PV) - keep walking up
2327+
dev = parents.into_iter().next().unwrap();
2328+
}
2329+
};
2330+
tracing::debug!("Backing devices: {backing_devices:?}");
2331+
2332+
// Determine the device and partition info to use for bootloader installation.
2333+
// If there are multiple backing devices, we search for all that contain an ESP.
2334+
let device_info: Vec<bootc_blockdev::PartitionTable> = if backing_devices.len() == 1 {
2335+
// Single backing device - use it directly
2336+
let dev = &backing_devices[0];
2337+
vec![bootc_blockdev::partitions_of(Utf8Path::new(dev))?]
2338+
} else {
2339+
// Multiple backing devices - find all with ESP
2340+
let mut esp_devices = Vec::new();
2341+
for dev in &backing_devices {
2342+
match bootc_blockdev::partitions_of(Utf8Path::new(dev)) {
2343+
Ok(table) => {
2344+
if table.find_partition_of_esp()?.is_some() {
2345+
tracing::info!("Found ESP on device {dev}");
2346+
esp_devices.push(table);
2347+
}
2348+
}
2349+
Err(e) => {
2350+
// Some backing devices may not have partition tables (e.g., raw LVM PVs
2351+
// or whole-disk filesystems). These can't have an ESP, so skip them.
2352+
tracing::debug!("Failed to read partition table from {dev}: {e}");
2353+
}
23082354
}
2309-
dev = parent;
23102355
}
2311-
dev
2356+
if esp_devices.is_empty() {
2357+
// No ESP found on any backing device. This is not fatal because:
2358+
// - BIOS boot uses MBR, not ESP
2359+
// - bootupd may auto-detect ESP via mounted /boot/efi
2360+
// However, UEFI boot without a detectable ESP will fail.
2361+
tracing::warn!(
2362+
"No ESP found on any backing device ({:?}); UEFI boot may fail",
2363+
backing_devices
2364+
);
2365+
}
2366+
esp_devices
23122367
};
2313-
tracing::debug!("Backing device: {backing_device}");
2314-
let device_info = bootc_blockdev::partitions_of(Utf8Path::new(&backing_device))?;
23152368

23162369
let rootarg = format!("root={}", root_info.mount_spec);
23172370
let mut boot = if let Some(spec) = fsopts.boot_mount_spec {

crates/lib/src/install/baseline.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ pub(crate) fn install_create_rootfs(
485485
BlockSetup::Direct => None,
486486
BlockSetup::Tpm2Luks => Some(luks_name.to_string()),
487487
};
488-
let device_info = bootc_blockdev::partitions_of(&devpath)?;
488+
let device_info = vec![bootc_blockdev::partitions_of(&devpath)?];
489489
Ok(RootSetup {
490490
luks_device,
491491
device_info,

tmt/plans/integration.fmf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,10 @@ execute:
166166
how: fmf
167167
test:
168168
- /tmt/tests/tests/test-33-bib-build
169+
/plan-34-multi-device-esp:
170+
summary: Test multi-device ESP detection for to-existing-root
171+
discover:
172+
how: fmf
173+
test:
174+
- /tmt/tests/test-32-multi-device-esp
169175
# END GENERATED PLANS

0 commit comments

Comments
 (0)