Skip to content
Draft
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
22 changes: 17 additions & 5 deletions crates/lib/src/bootc_composefs/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,13 @@ pub(crate) fn setup_composefs_bls_boot(

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

// Locate ESP partition device
let esp_part = esp_in(&root_setup.device_info)?;
// Locate ESP partition device (use first device)
// TODO: Handle multiple devices (RAID, LVM, etc)
Copy link
Collaborator

Choose a reason for hiding this comment

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

AFAIK non-redundant multi-device composefs setups (with systemd-boot e.g.) should work where there's just one ESP.

So I think it should work here to walk the blockdevs until we find an ESP, but we would need to error out if there are multiple.

let device_info = root_setup
.device_info
.first()
.ok_or_else(|| anyhow!("Cannot locate ESP: no backing device found"))?;
let esp_part = esp_in(device_info)?;

(
root_setup.physical_root_path.clone(),
Expand Down Expand Up @@ -1063,7 +1068,12 @@ pub(crate) fn setup_composefs_uki_boot(
BootSetupType::Setup((root_setup, state, postfetch, ..)) => {
state.require_no_kargs_for_uki()?;

let esp_part = esp_in(&root_setup.device_info)?;
//TODO: Handle multiple devices (RAID, LVM, etc)
let device_info = root_setup
.device_info
.first()
.ok_or_else(|| anyhow!("Cannot locate ESP: no backing device found"))?;
let esp_part = esp_in(device_info)?;

(
root_setup.physical_root_path.clone(),
Expand Down Expand Up @@ -1233,7 +1243,8 @@ pub(crate) async fn setup_composefs_boot(

if cfg!(target_arch = "s390x") {
// TODO: Integrate s390x support into install_via_bootupd
crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?;
// zipl only supports single device
crate::bootloader::install_via_zipl(root_setup.device_info.first(), boot_uuid)?;
} else if postfetch.detected_bootloader == Bootloader::Grub {
crate::bootloader::install_via_bootupd(
&root_setup.device_info,
Expand All @@ -1242,8 +1253,9 @@ pub(crate) async fn setup_composefs_boot(
None,
)?;
} else {
// systemd-boot only supports single device
Copy link
Collaborator

Choose a reason for hiding this comment

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

only a single ESP

crate::bootloader::install_systemd_boot(
&root_setup.device_info,
root_setup.device_info.first(),
&root_setup.physical_root_path,
&state.config_opts,
None,
Expand Down
69 changes: 56 additions & 13 deletions crates/lib/src/bootloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result<bool> {

#[context("Installing bootloader")]
pub(crate) fn install_via_bootupd(
device: &PartitionTable,
devices: &[PartitionTable],
rootfs: &Utf8Path,
configopts: &crate::install::InstallConfigOpts,
deployment_path: Option<&str>,
Expand All @@ -97,26 +97,61 @@ pub(crate) fn install_via_bootupd(
} else {
vec![]
};
let devpath = device.path();
println!("Installing bootloader via bootupd");
Command::new("bootupctl")
.args(["backend", "install", "--write-uuid"])
.args(verbose)
.args(bootupd_opts.iter().copied().flatten())
.args(src_root_arg)
.args(["--device", devpath.as_str(), rootfs.as_str()])
.log_debug()
.run_inherited_with_cmd_context()

// No backing devices with ESP found. Run bootupd without --device and let it
// try to auto-detect. This works for:
// - BIOS boot (uses MBR, not ESP)
// - Systems where bootupd can find ESP via mounted /boot/efi
// UEFI boot will fail if bootupd cannot locate the ESP.
if devices.is_empty() {
tracing::warn!(
"No backing device with ESP found; UEFI boot may fail if ESP cannot be auto-detected"
);
println!("Installing bootloader via bootupd (no target device specified)");
return Command::new("bootupctl")
.args(["backend", "install", "--write-uuid"])
.args(verbose)
.args(bootupd_opts.iter().copied().flatten())
.args(&src_root_arg)
.arg(rootfs.as_str())
.log_debug()
.run_inherited_with_cmd_context();
}

// Install bootloader to each device
for dev in devices {
let devpath = dev.path();
println!("Installing bootloader via bootupd to {devpath}");
Command::new("bootupctl")
.args(["backend", "install", "--write-uuid"])
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 this will lead to a kind of last-one wins behavior for bootupd.json - but in the end they should be identical I guess?

cc @HuijingHei

We probably want to document the right way to do multi-device installs there. (and have man pages in general)

Alternatively it might be nicer to explicitly support this in bootupd by just passing each device?

Copy link
Contributor

@HuijingHei HuijingHei Jan 16, 2026

Choose a reason for hiding this comment

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

I think this will lead to a kind of last-one wins behavior for bootupd.json - but in the end they should be identical I guess?

Agree, but we need this like RAID.

Alternatively it might be nicer to explicitly support this in bootupd by just passing each device?

That will be cleaner, and we could do this only if we make bootupd not fail if the passed device does not have the esp device.

.args(verbose)
.args(bootupd_opts.iter().copied().flatten())
.args(&src_root_arg)
.args(["--device", devpath.as_str()])
.arg(rootfs.as_str())
.log_debug()
.run_inherited_with_cmd_context()?;
}

Ok(())
}

#[context("Installing bootloader")]
pub(crate) fn install_systemd_boot(
device: &PartitionTable,
device: Option<&PartitionTable>,
_rootfs: &Utf8Path,
_configopts: &crate::install::InstallConfigOpts,
_deployment_path: Option<&str>,
autoenroll: Option<SecurebootKeys>,
) -> Result<()> {
// systemd-boot requires the backing device to locate the ESP partition
let device = device.ok_or_else(|| {
anyhow!(
"Cannot install systemd-boot: no single backing device found \
(root may span multiple devices such as LVM across multiple disks)"
)
})?;

let esp_part = device
.find_partition_of_type(discoverable_partition_specification::ESP)
.ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?;
Expand Down Expand Up @@ -161,7 +196,15 @@ pub(crate) fn install_systemd_boot(
}

#[context("Installing bootloader using zipl")]
pub(crate) fn install_via_zipl(device: &PartitionTable, boot_uuid: &str) -> Result<()> {
pub(crate) fn install_via_zipl(device: Option<&PartitionTable>, boot_uuid: &str) -> Result<()> {
// zipl requires the backing device information to install the bootloader
let device = device.ok_or_else(|| {
anyhow!(
"Cannot install zipl bootloader: no single backing device found \
(root may span multiple devices such as LVM across multiple disks)"
)
})?;

// Identify the target boot partition from UUID
let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
let boot_dir = Utf8Path::new(&fs.target);
Expand Down
103 changes: 78 additions & 25 deletions crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,10 @@ pub(crate) fn exec_in_host_mountns(args: &[std::ffi::OsString]) -> Result<()> {
pub(crate) struct RootSetup {
#[cfg(feature = "install-to-disk")]
luks_device: Option<String>,
pub(crate) device_info: bootc_blockdev::PartitionTable,
/// Information about the backing block device partition tables.
/// Contains all devices that have an ESP partition when the root filesystem
/// spans multiple backing devices (e.g., LVM across multiple disks).
pub(crate) device_info: Vec<bootc_blockdev::PartitionTable>,
/// Absolute path to the location where we've mounted the physical
/// root filesystem for the system we're installing.
pub(crate) physical_root_path: Utf8PathBuf,
Expand Down Expand Up @@ -1588,7 +1591,9 @@ async fn install_with_sysroot(

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

match &rootfs.device_info.label {
bootc_blockdev::PartitionType::Dos => crate::utils::medium_visibility_warning(
"Installing to `dos` format partitions is not recommended",
),
bootc_blockdev::PartitionType::Gpt => {
// The only thing we should be using in general
}
bootc_blockdev::PartitionType::Unknown(o) => {
crate::utils::medium_visibility_warning(&format!("Unknown partition label {o}"))
// Check partition type of all backing devices
for device_info in &rootfs.device_info {
match &device_info.label {
bootc_blockdev::PartitionType::Dos => {
crate::utils::medium_visibility_warning(&format!(
"Installing to `dos` format partitions is not recommended: {}",
device_info.path()
))
}
bootc_blockdev::PartitionType::Gpt => {
// The only thing we should be using in general
}
bootc_blockdev::PartitionType::Unknown(o) => crate::utils::medium_visibility_warning(
&format!("Unknown partition label {o}: {}", device_info.path()),
),
}
}

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

// Find the real underlying backing device for the root. This is currently just required
// for GRUB (BIOS) and in the future zipl (I think).
let backing_device = {
// Walk up the block device hierarchy to find physical backing device(s).
// Examples:
// /dev/sda3 -> /dev/sda (single disk)
// /dev/mapper/vg-lv -> /dev/sda2, /dev/sdb2 (LVM across two disks)
let backing_devices: Vec<String> = {
let mut dev = inspect.source;
loop {
tracing::debug!("Finding parents for {dev}");
let mut parents = bootc_blockdev::find_parent_devices(&dev)?.into_iter();
let Some(parent) = parents.next() else {
break;
};
if let Some(next) = parents.next() {
anyhow::bail!(
"Found multiple parent devices {parent} and {next}; not currently supported"
let parents = bootc_blockdev::find_parent_devices(&dev)?;
if parents.is_empty() {
// Reached a physical disk
break vec![dev];
}
if parents.len() > 1 {
// Multi-device (e.g., LVM across disks) - return all
tracing::debug!(
"Found multiple parent devices: {:?}; will search for ESP",
parents
);
break parents;
}
// Single parent (e.g. LVM LV -> VG -> PV) - keep walking up
dev = parents.into_iter().next().unwrap();
}
};
tracing::debug!("Backing devices: {backing_devices:?}");

// Determine the device and partition info to use for bootloader installation.
// If there are multiple backing devices, we search for all that contain an ESP.
let device_info: Vec<bootc_blockdev::PartitionTable> = if backing_devices.len() == 1 {
// Single backing device - use it directly
let dev = &backing_devices[0];
vec![bootc_blockdev::partitions_of(Utf8Path::new(dev))?]
} else {
// Multiple backing devices - find all with ESP
let mut esp_devices = Vec::new();
for dev in &backing_devices {
match bootc_blockdev::partitions_of(Utf8Path::new(dev)) {
Ok(table) => {
if table.find_partition_of_esp()?.is_some() {
tracing::info!("Found ESP on device {dev}");
esp_devices.push(table);
}
Comment on lines +2330 to +2333
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The use of ? here could cause the entire installation to fail if find_partition_of_esp() returns an error (e.g., for an unsupported partition table type). This might be undesirable, especially in a multi-device setup where one device having an unsupported format shouldn't prevent bootloader installation on other valid devices.

Consider handling the Result from find_partition_of_esp() explicitly to log the error and continue, similar to how errors from partitions_of() are handled. This would make the process more robust.

                    match table.find_partition_of_esp() {
                        Ok(Some(_)) => {
                            tracing::info!("Found ESP on device {dev}");
                            esp_devices.push(table);
                        }
                        Ok(None) => (),
                        Err(e) => {
                            tracing::debug!("Could not check for ESP on {dev}: {e}");
                        }
                    }

}
Err(e) => {
// Some backing devices may not have partition tables (e.g., raw LVM PVs
// or whole-disk filesystems). These can't have an ESP, so skip them.
tracing::debug!("Failed to read partition table from {dev}: {e}");
}
}
dev = parent;
}
dev
if esp_devices.is_empty() {
// No ESP found on any backing device. This is not fatal because:
// - BIOS boot uses MBR, not ESP
// - bootupd may auto-detect ESP via mounted /boot/efi
// However, UEFI boot without a detectable ESP will fail.
tracing::warn!(
"No ESP found on any backing device ({:?}); UEFI boot may fail",
backing_devices
);
}
esp_devices
};
tracing::debug!("Backing device: {backing_device}");
let device_info = bootc_blockdev::partitions_of(Utf8Path::new(&backing_device))?;

let rootarg = format!("root={}", root_info.mount_spec);
let mut boot = if let Some(spec) = fsopts.boot_mount_spec {
Expand Down
2 changes: 1 addition & 1 deletion crates/lib/src/install/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ pub(crate) fn install_create_rootfs(
BlockSetup::Direct => None,
BlockSetup::Tpm2Luks => Some(luks_name.to_string()),
};
let device_info = bootc_blockdev::partitions_of(&devpath)?;
let device_info = vec![bootc_blockdev::partitions_of(&devpath)?];
Ok(RootSetup {
luks_device,
device_info,
Expand Down
7 changes: 7 additions & 0 deletions tmt/plans/integration.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,11 @@ execute:
how: fmf
test:
- /tmt/tests/tests/test-32-install-to-filesystem-var-mount

/plan-33-multi-device-esp:
summary: Test multi-device ESP detection for to-existing-root
discover:
how: fmf
test:
- /tmt/tests/test-32-multi-device-esp
# END GENERATED PLANS
Loading
Loading