Skip to content

Commit f7b1892

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 e074a41 commit f7b1892

File tree

7 files changed

+398
-44
lines changed

7 files changed

+398
-44
lines changed

crates/lib/src/bootc_composefs/boot.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -520,8 +520,13 @@ 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+
// Locate ESP partition device (use first device)
524+
// TODO: Handle multiple devices (RAID, LVM, etc)
525+
let device_info = root_setup
526+
.device_info
527+
.first()
528+
.ok_or_else(|| anyhow!("Cannot locate ESP: no backing device found"))?;
529+
let esp_part = esp_in(device_info)?;
525530

526531
(
527532
root_setup.physical_root_path.clone(),
@@ -1063,7 +1068,12 @@ pub(crate) fn setup_composefs_uki_boot(
10631068
BootSetupType::Setup((root_setup, state, postfetch, ..)) => {
10641069
state.require_no_kargs_for_uki()?;
10651070

1066-
let esp_part = esp_in(&root_setup.device_info)?;
1071+
//TODO: Handle multiple devices (RAID, LVM, etc)
1072+
let device_info = root_setup
1073+
.device_info
1074+
.first()
1075+
.ok_or_else(|| anyhow!("Cannot locate ESP: no backing device found"))?;
1076+
let esp_part = esp_in(device_info)?;
10671077

10681078
(
10691079
root_setup.physical_root_path.clone(),
@@ -1233,7 +1243,8 @@ pub(crate) async fn setup_composefs_boot(
12331243

12341244
if cfg!(target_arch = "s390x") {
12351245
// TODO: Integrate s390x support into install_via_bootupd
1236-
crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?;
1246+
// zipl only supports single device
1247+
crate::bootloader::install_via_zipl(root_setup.device_info.first(), boot_uuid)?;
12371248
} else if postfetch.detected_bootloader == Bootloader::Grub {
12381249
crate::bootloader::install_via_bootupd(
12391250
&root_setup.device_info,
@@ -1242,8 +1253,9 @@ pub(crate) async fn setup_composefs_boot(
12421253
None,
12431254
)?;
12441255
} else {
1256+
// systemd-boot only supports single device
12451257
crate::bootloader::install_systemd_boot(
1246-
&root_setup.device_info,
1258+
root_setup.device_info.first(),
12471259
&root_setup.physical_root_path,
12481260
&state.config_opts,
12491261
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
@@ -1127,7 +1127,10 @@ pub(crate) fn exec_in_host_mountns(args: &[std::ffi::OsString]) -> Result<()> {
11271127
pub(crate) struct RootSetup {
11281128
#[cfg(feature = "install-to-disk")]
11291129
luks_device: Option<String>,
1130-
pub(crate) device_info: bootc_blockdev::PartitionTable,
1130+
/// Information about the backing block device partition tables.
1131+
/// Contains all devices that have an ESP partition when the root filesystem
1132+
/// spans multiple backing devices (e.g., LVM across multiple disks).
1133+
pub(crate) device_info: Vec<bootc_blockdev::PartitionTable>,
11311134
/// Absolute path to the location where we've mounted the physical
11321135
/// root filesystem for the system we're installing.
11331136
pub(crate) physical_root_path: Utf8PathBuf,
@@ -1588,7 +1591,9 @@ async fn install_with_sysroot(
15881591

15891592
if cfg!(target_arch = "s390x") {
15901593
// TODO: Integrate s390x support into install_via_bootupd
1591-
crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?;
1594+
// zipl only supports single device
1595+
let device = rootfs.device_info.first();
1596+
crate::bootloader::install_via_zipl(device, boot_uuid)?;
15921597
} else {
15931598
match postfetch.detected_bootloader {
15941599
Bootloader::Grub => {
@@ -1719,15 +1724,21 @@ async fn install_to_filesystem_impl(
17191724
// Drop exclusive ownership since we're done with mutation
17201725
let rootfs = &*rootfs;
17211726

1722-
match &rootfs.device_info.label {
1723-
bootc_blockdev::PartitionType::Dos => crate::utils::medium_visibility_warning(
1724-
"Installing to `dos` format partitions is not recommended",
1725-
),
1726-
bootc_blockdev::PartitionType::Gpt => {
1727-
// The only thing we should be using in general
1728-
}
1729-
bootc_blockdev::PartitionType::Unknown(o) => {
1730-
crate::utils::medium_visibility_warning(&format!("Unknown partition label {o}"))
1727+
// Check partition type of all backing devices
1728+
for device_info in &rootfs.device_info {
1729+
match &device_info.label {
1730+
bootc_blockdev::PartitionType::Dos => {
1731+
crate::utils::medium_visibility_warning(&format!(
1732+
"Installing to `dos` format partitions is not recommended: {}",
1733+
device_info.path()
1734+
))
1735+
}
1736+
bootc_blockdev::PartitionType::Gpt => {
1737+
// The only thing we should be using in general
1738+
}
1739+
bootc_blockdev::PartitionType::Unknown(o) => crate::utils::medium_visibility_warning(
1740+
&format!("Unknown partition label {o}: {}", device_info.path()),
1741+
),
17311742
}
17321743
}
17331744

@@ -2277,27 +2288,69 @@ pub(crate) async fn install_to_filesystem(
22772288
};
22782289
tracing::debug!("boot UUID: {boot_uuid:?}");
22792290

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

23022355
let rootarg = format!("root={}", root_info.mount_spec);
23032356
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,11 @@ execute:
159159
how: fmf
160160
test:
161161
- /tmt/tests/tests/test-32-install-to-filesystem-var-mount
162+
163+
/plan-33-multi-device-esp:
164+
summary: Test multi-device ESP detection for to-existing-root
165+
discover:
166+
how: fmf
167+
test:
168+
- /tmt/tests/test-32-multi-device-esp
162169
# END GENERATED PLANS

0 commit comments

Comments
 (0)