Skip to content
Merged
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
83 changes: 54 additions & 29 deletions crates/blockdev/src/blockdev.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::env;
use std::path::Path;
use std::process::{Command, Stdio};
Expand Down Expand Up @@ -123,15 +124,26 @@ impl Device {
/// Calls find_all_roots() to discover physical disks, then searches each for an ESP.
/// Returns None if no ESPs are found.
pub fn find_colocated_esps(&self) -> Result<Option<Vec<Device>>> {
let esps: Vec<_> = self
.find_all_roots()?
.iter()
.flat_map(|root| root.find_partition_of_esp().ok())
.cloned()
.collect();
let mut esps = Vec::new();
for root in &self.find_all_roots()? {
if let Some(esp) = root.find_partition_of_esp_optional()? {
esps.push(esp.clone());
}
}
Ok((!esps.is_empty()).then_some(esps))
}

/// Find a single ESP partition among all root devices backing this device.
///
/// Walks the parent chain to find all backing disks, then looks for ESP
/// partitions on each. Returns the first ESP found. This is the common
/// case for composefs/UKI boot paths where exactly one ESP is expected.
pub fn find_first_colocated_esp(&self) -> Result<Device> {
self.find_colocated_esps()?
.and_then(|mut v| Some(v.remove(0)))
.ok_or_else(|| anyhow!("No ESP partition found among backing devices"))
}

/// Find all BIOS boot partitions across all root devices backing this device.
/// Calls find_all_roots() to discover physical disks, then searches each for a BIOS boot partition.
/// Returns None if no BIOS boot partitions are found.
Expand Down Expand Up @@ -159,34 +171,41 @@ impl Device {
///
/// For GPT disks, this matches by the ESP partition type GUID.
/// For MBR (dos) disks, this matches by the MBR partition type IDs (0x06 or 0xEF).
pub fn find_partition_of_esp(&self) -> Result<&Device> {
let children = self
.children
.as_ref()
.ok_or_else(|| anyhow!("Device has no children"))?;
///
/// Returns `Ok(None)` when there are no children or no ESP partition
/// is present. Returns `Err` only for genuinely unexpected conditions
/// (e.g. an unsupported partition table type).
pub fn find_partition_of_esp_optional(&self) -> Result<Option<&Device>> {
let Some(children) = self.children.as_ref() else {
return Ok(None);
};
match self.pttype.as_deref() {
Some("dos") => children
.iter()
.find(|child| {
child
.parttype
.as_ref()
.and_then(|pt| {
let pt = pt.strip_prefix("0x").unwrap_or(pt);
u8::from_str_radix(pt, 16).ok()
})
.is_some_and(|pt| ESP_ID_MBR.contains(&pt))
})
.ok_or_else(|| anyhow!("ESP not found in MBR partition table")),
Some("dos") => Ok(children.iter().find(|child| {
child
.parttype
.as_ref()
.and_then(|pt| {
let pt = pt.strip_prefix("0x").unwrap_or(pt);
u8::from_str_radix(pt, 16).ok()
})
.is_some_and(|pt| ESP_ID_MBR.contains(&pt))
})),
// When pttype is None (e.g. older lsblk or partition devices), default
// to GPT UUID matching which will simply not match MBR hex types.
Some("gpt") | None => self
.find_partition_of_type(ESP)
.ok_or_else(|| anyhow!("ESP not found in GPT partition table")),
Some("gpt") | None => Ok(self.find_partition_of_type(ESP)),
Some(other) => Err(anyhow!("Unsupported partition table type: {other}")),
}
}

/// Find the EFI System Partition (ESP) among children, or error if absent.
///
/// This is a convenience wrapper around [`Self::find_partition_of_esp_optional`]
/// for callers that require an ESP to be present.
pub fn find_partition_of_esp(&self) -> Result<&Device> {
self.find_partition_of_esp_optional()?
.ok_or_else(|| anyhow!("ESP partition not found on {}", self.path()))
}

/// Find a child partition by partition number (1-indexed).
pub fn find_device_by_partno(&self, partno: u32) -> Result<&Device> {
self.children
Expand Down Expand Up @@ -308,15 +327,21 @@ impl Device {
};

let mut roots = Vec::new();
let mut seen = HashSet::new();
let mut queue = parents;
while let Some(mut device) = queue.pop() {
match device.children.take() {
Some(grandparents) if !grandparents.is_empty() => {
queue.extend(grandparents);
}
_ => {
// Found a root; re-query to populate its actual children
roots.push(list_dev(Utf8Path::new(&device.path()))?);
// Deduplicate: in complex topologies (e.g. multipath)
// multiple branches can converge on the same physical disk.
let name = device.name.clone();
if seen.insert(name) {
// Found a new root; re-query to populate its actual children
roots.push(list_dev(Utf8Path::new(&device.path()))?);
}
}
}
}
Expand Down
21 changes: 10 additions & 11 deletions crates/lib/src/bootc_composefs/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,8 @@ pub(crate) fn setup_composefs_bls_boot(
}
}

// Locate ESP partition device
let esp_part = root_setup.device_info.find_partition_of_esp()?;
// Locate ESP partition device by walking up to the root disk(s)
let esp_part = root_setup.device_info.find_first_colocated_esp()?;

(
root_setup.physical_root_path.clone(),
Expand Down Expand Up @@ -586,10 +586,9 @@ pub(crate) fn setup_composefs_bls_boot(
.context("Failed to create 'composefs=' parameter")?;
cmdline.add_or_modify(&param);

// Locate ESP partition device
let root_dev =
bootc_blockdev::list_dev_by_dir(&storage.physical_root)?.require_single_root()?;
let esp_dev = root_dev.find_partition_of_esp()?;
// Locate ESP partition device by walking up to the root disk(s)
let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?;
let esp_dev = root_dev.find_first_colocated_esp()?;

(
Utf8PathBuf::from("/sysroot"),
Expand Down Expand Up @@ -1097,7 +1096,8 @@ pub(crate) fn setup_composefs_uki_boot(
BootSetupType::Setup((root_setup, state, postfetch, ..)) => {
state.require_no_kargs_for_uki()?;

let esp_part = root_setup.device_info.find_partition_of_esp()?;
// Locate ESP partition device by walking up to the root disk(s)
let esp_part = root_setup.device_info.find_first_colocated_esp()?;

(
root_setup.physical_root_path.clone(),
Expand All @@ -1112,10 +1112,9 @@ pub(crate) fn setup_composefs_uki_boot(
let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path
let bootloader = host.require_composefs_booted()?.bootloader.clone();

// Locate ESP partition device
let root_dev =
bootc_blockdev::list_dev_by_dir(&storage.physical_root)?.require_single_root()?;
let esp_dev = root_dev.find_partition_of_esp()?;
// Locate ESP partition device by walking up to the root disk(s)
let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?;
let esp_dev = root_dev.find_first_colocated_esp()?;

(
sysroot,
Expand Down
128 changes: 106 additions & 22 deletions crates/lib/src/bootloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use fn_error_context::context;
use bootc_mount as mount;

use crate::bootc_composefs::boot::{SecurebootKeys, mount_esp};
use crate::{discoverable_partition_specification, utils};
use crate::utils;

/// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel)
pub(crate) const EFI_DIR: &str = "efi";
Expand All @@ -23,7 +23,13 @@ const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates";
// from: https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392
const SYSTEMD_KEY_DIR: &str = "loader/keys";

/// Mount ESP part at /boot/efi
/// Mount the first ESP found among backing devices at /boot/efi.
///
/// This is used by the install-alongside path to clean stale bootloader
/// files before reinstallation. On multi-device setups only the first
/// ESP is mounted and cleaned; stale files on additional ESPs are left
/// in place (bootupd will overwrite them during installation).
// TODO: clean all ESPs on multi-device setups
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmm yeah maybe should move this logic into bootupd

pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) -> Result<()> {
let efi_path = Utf8Path::new("boot").join(crate::bootloader::EFI_DIR);
let Some(esp_fd) = root
Expand All @@ -45,12 +51,19 @@ pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool)
root
};

let dev = bootc_blockdev::list_dev_by_dir(physical_root)?.require_single_root()?;
if let Some(esp_dev) = dev.find_partition_of_type(bootc_blockdev::ESP) {
let esp_path = esp_dev.path();
bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?;
tracing::debug!("Mounted {esp_path} at /boot/efi");
let roots = bootc_blockdev::list_dev_by_dir(physical_root)?.find_all_roots()?;
for dev in &roots {
if let Some(esp_dev) = dev.find_partition_of_esp_optional()? {
let esp_path = esp_dev.path();
bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?;
tracing::debug!("Mounted {esp_path} at /boot/efi");
return Ok(());
}
}
tracing::debug!(
"No ESP partition found among {} root device(s)",
roots.len()
);
Ok(())
}

Expand All @@ -67,6 +80,45 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result<bool> {
Ok(r)
}

/// Check whether the target bootupd supports `--filesystem`.
///
/// Runs `bootupctl backend install --help` and looks for `--filesystem` in the
/// output. When `deployment_path` is set the command runs inside a bwrap
/// container so we probe the binary from the target image.
fn bootupd_supports_filesystem(rootfs: &Utf8Path, deployment_path: Option<&str>) -> Result<bool> {
let help_args = ["bootupctl", "backend", "install", "--help"];
let output = if let Some(deploy) = deployment_path {
let target_root = rootfs.join(deploy);
BwrapCmd::new(&target_root)
.set_default_path()
.run_get_string(help_args)?
} else {
Command::new("bootupctl")
.args(&help_args[1..])
.log_debug()
.run_get_string()?
};

let use_filesystem = output.contains("--filesystem");

if use_filesystem {
tracing::debug!("bootupd supports --filesystem");
} else {
tracing::debug!("bootupd does not support --filesystem, falling back to --device");
}

Ok(use_filesystem)
}

/// Install the bootloader via bootupd.
///
/// When the target bootupd supports `--filesystem` we pass it pointing at a
/// block-backed mount so that bootupd can resolve the backing device(s) itself
/// via `lsblk`. In the bwrap path we bind-mount the physical root at
/// `/sysroot` to give `lsblk` a real block-backed path.
///
/// For older bootupd versions that lack `--filesystem` we fall back to the
/// legacy `--device <device_path> <rootfs>` invocation.
#[context("Installing bootloader")]
pub(crate) fn install_via_bootupd(
device: &bootc_blockdev::Device,
Expand All @@ -91,8 +143,6 @@ pub(crate) fn install_via_bootupd(

println!("Installing bootloader via bootupd");

let device_path = device.path();

// Build the bootupctl arguments
let mut bootupd_args: Vec<&str> = vec!["backend", "install"];
if configopts.bootupd_skip_boot_uuid {
Expand All @@ -107,35 +157,58 @@ pub(crate) fn install_via_bootupd(
if let Some(ref opts) = bootupd_opts {
bootupd_args.extend(opts.iter().copied());
}
bootupd_args.extend(["--device", &device_path, rootfs_mount]);

// When the target bootupd lacks --filesystem support, fall back to the
// legacy --device flag. For --device we need the whole-disk device path
// (e.g. /dev/vda), not a partition (e.g. /dev/vda3), so resolve the
// parent via require_single_root(). (Older bootupd doesn't support
// multiple backing devices anyway.)
// Computed before building bootupd_args so the String lives long enough.
let root_device_path = if bootupd_supports_filesystem(rootfs, deployment_path)
.context("Probing bootupd --filesystem support")?
{
None
} else {
Some(device.require_single_root()?.path())
};
if let Some(ref dev) = root_device_path {
tracing::debug!("bootupd does not support --filesystem, falling back to --device {dev}");
bootupd_args.extend(["--device", dev]);
bootupd_args.push(rootfs_mount);
} else {
tracing::debug!("bootupd supports --filesystem");
bootupd_args.extend(["--filesystem", rootfs_mount]);
bootupd_args.push(rootfs_mount);
}

// Run inside a bwrap container. It takes care of mounting and creating
// the necessary API filesystems in the target deployment and acts as
// a nicer `chroot`.
if let Some(deploy) = deployment_path {
let target_root = rootfs.join(deploy);
let boot_path = rootfs.join("boot");
let rootfs_path = rootfs.to_path_buf();

tracing::debug!("Running bootupctl via bwrap in {}", target_root);

// Prepend "bootupctl" to the args for bwrap
let mut bwrap_args = vec!["bootupctl"];
bwrap_args.extend(bootupd_args);

let cmd = BwrapCmd::new(&target_root)
let mut cmd = BwrapCmd::new(&target_root)
// Bind mount /boot from the physical target root so bootupctl can find
// the boot partition and install the bootloader there
.bind(&boot_path, &"/boot");

// Only bind mount the physical root at /sysroot when using --filesystem;
// bootupd needs it to resolve backing block devices via lsblk.
if root_device_path.is_none() {
cmd = cmd.bind(&rootfs_path, &"/sysroot");
}

// The $PATH in the bwrap env is not complete enough for some images
// so we inject a reasonnable default.
// This is causing bootupctl and/or sfdisk binaries
// to be not found with fedora 43.
cmd.setenv(
"PATH",
"/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin",
)
.run(bwrap_args)
// so we inject a reasonable default.
cmd.set_default_path().run(bwrap_args)
} else {
// Running directly without chroot
Command::new("bootupctl")
Expand All @@ -145,6 +218,11 @@ pub(crate) fn install_via_bootupd(
}
}

/// Install systemd-boot to the first ESP found among backing devices.
///
/// On multi-device setups only the first ESP is installed to; additional
/// ESPs on other backing devices are left untouched.
// TODO: install to all ESPs on multi-device setups
#[context("Installing bootloader")]
pub(crate) fn install_systemd_boot(
device: &bootc_blockdev::Device,
Expand All @@ -153,9 +231,15 @@ pub(crate) fn install_systemd_boot(
_deployment_path: Option<&str>,
autoenroll: Option<SecurebootKeys>,
) -> Result<()> {
let esp_part = device
.find_partition_of_type(discoverable_partition_specification::ESP)
.ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?;
let roots = device.find_all_roots()?;
let mut esp_part = None;
for root in &roots {
if let Some(esp) = root.find_partition_of_esp_optional()? {
esp_part = Some(esp);
break;
}
}
let esp_part = esp_part.ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?;

let esp_mount = mount_esp(&esp_part.path()).context("Mounting ESP")?;
let esp_path = Utf8Path::from_path(esp_mount.dir.path())
Expand Down
5 changes: 2 additions & 3 deletions crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2611,9 +2611,8 @@ pub(crate) async fn install_to_filesystem(
// 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 device_info = {
let dev =
bootc_blockdev::list_dev(Utf8Path::new(&inspect.source))?.require_single_root()?;
tracing::debug!("Backing device: {}", dev.path());
let dev = bootc_blockdev::list_dev(Utf8Path::new(&inspect.source))?;
tracing::debug!("Target filesystem backing device: {}", dev.path());
dev
};

Expand Down
Loading