diff --git a/Cargo.lock b/Cargo.lock index ae8950ac4c..395ba7cdef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,9 +302,11 @@ dependencies = [ "bootc-internal-utils", "camino", "cap-std-ext", + "cfg-if", "fn-error-context", "indoc", "libc", + "regex", "rustix", "serde", "serde_json", @@ -369,7 +371,6 @@ dependencies = [ "canon-json", "cap-std-ext", "cargo_metadata", - "cfg-if", "chrono", "clap", "clap_complete", @@ -395,7 +396,6 @@ dependencies = [ "openssl", "ostree-ext", "percent-encoding", - "regex", "rustix", "schemars", "selinux", diff --git a/crates/blockdev/Cargo.toml b/crates/blockdev/Cargo.toml index 90a9a3cf3d..0749f7e7c5 100644 --- a/crates/blockdev/Cargo.toml +++ b/crates/blockdev/Cargo.toml @@ -13,6 +13,7 @@ bootc-mount = { package = "bootc-internal-mount", path = "../mount", version = " # Workspace dependencies anyhow = { workspace = true } +cfg-if = { workspace = true } camino = { workspace = true, features = ["serde1"] } cap-std-ext = { workspace = true, features = ["fs_utf8"] } fn-error-context = { workspace = true } @@ -26,6 +27,7 @@ tracing = { workspace = true } [dev-dependencies] indoc = { workspace = true } +regex = { workspace = true } [lib] path = "src/blockdev.rs" diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 1906efa025..6b6c292b36 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -12,6 +12,9 @@ use serde::Deserialize; use bootc_utils::CommandRunExt; +pub mod dps; +use dps::{BIOS_BOOT, ESP, XBOOTLDR}; + /// Check whether the udev database is accessible (cached for the process lifetime). /// /// When running inside a container or sandbox without `/run/udev` @@ -76,12 +79,6 @@ fn blkid_probe(dev: &str) -> Result> { /// Refer to pub const ESP_ID_MBR: &[u8] = &[0x06, 0xEF]; -/// EFI System Partition (ESP) for UEFI boot on GPT -pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; - -/// BIOS boot partition type GUID for GPT -pub const BIOS_BOOT: &str = "21686148-6449-6e6f-744e-656564454649"; - #[derive(Debug, Deserialize)] struct DevicesOutput { blockdevices: Vec, @@ -240,6 +237,27 @@ impl Device { }) } + /// Find a VFAT Extended Boot Loader Partition (XBOOTLDR) among children. + /// + /// Other filesystems are skipped: an XBOOTLDR partition holding UKIs and + /// boot entries for systemd-boot must be readable by the firmware. + pub fn find_partition_of_xbootldr(&self) -> Option<&Device> { + self.find_partition_of_type(XBOOTLDR) + .filter(|part| part.fstype.as_deref() == Some("vfat")) + } + + /// Find a VFAT Extended Boot Loader Partition (XBOOTLDR) on the same disk + /// as this partition, typically the ESP. + pub fn find_sibling_xbootldr(&self) -> Result> { + for parent in self.list_parents()?.unwrap_or_default() { + let parent = list_dev(Utf8Path::new(&parent.path()))?; + if let Some(xbootldr) = parent.find_partition_of_xbootldr() { + return Ok(Some(xbootldr.clone())); + } + } + Ok(None) + } + /// Find the EFI System Partition (ESP) among children. /// /// For GPT disks, this matches by the ESP partition type GUID. @@ -1051,6 +1069,33 @@ mod test { assert!(dev.find_partition_of_esp().is_err()); } + #[test] + fn test_find_partition_of_xbootldr() { + let cases = [ + (XBOOTLDR, Some("vfat"), Some(2)), + (XBOOTLDR, Some("ext4"), None), + (XBOOTLDR, None, None), + (ESP, Some("vfat"), None), + ]; + for (parttype, fstype, expected) in cases { + let disk: Device = serde_json::from_value(serde_json::json!({ + "name": "vda", + "size": 10737418240_u64, + "pttype": "gpt", + "children": [ + {"name": "vda1", "size": 104857600, "partn": 1, "parttype": ESP, "fstype": "vfat"}, + {"name": "vda2", "size": 2147483648_u64, "partn": 2, "parttype": parttype, "fstype": fstype}, + ] + })) + .unwrap(); + assert_eq!( + disk.find_partition_of_xbootldr().and_then(|p| p.partn), + expected, + "{parttype} {fstype:?}" + ); + } + } + #[test] fn test_parse_partition_number_from_suffix() { // Short alias like /dev/mapper/mpatha → /dev/mapper/mpatha2 diff --git a/crates/lib/src/discoverable_partition_specification.rs b/crates/blockdev/src/dps.rs similarity index 99% rename from crates/lib/src/discoverable_partition_specification.rs rename to crates/blockdev/src/dps.rs index 5aa0d36111..7d66f119fa 100644 --- a/crates/lib/src/discoverable_partition_specification.rs +++ b/crates/blockdev/src/dps.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - //! Partition type GUIDs from the Discoverable Partitions Specification (DPS) //! //! This module contains constants for partition type GUIDs as defined by the @@ -55,12 +53,10 @@ //! - **Verity signature partitions**: Signed verity root hashes //! - **Special partitions**: ESP, XBOOTLDR, swap, home, var, etc. //! -//! # Usage (internal) -//! -//! This is an internal module. Within bootc, it is used like: +//! # Usage //! //! ```ignore -//! use crate::discoverable_partition_specification::{this_arch_root, ESP}; +//! use bootc_blockdev::dps::{this_arch_root, ESP}; //! //! // Get the root partition type GUID for the current architecture //! let root_type: &str = this_arch_root(); diff --git a/crates/lib/src/fixtures/discoverable_partitions_specification.md b/crates/blockdev/src/fixtures/discoverable_partitions_specification.md similarity index 100% rename from crates/lib/src/fixtures/discoverable_partitions_specification.md rename to crates/blockdev/src/fixtures/discoverable_partitions_specification.md diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 95770cb9ce..75a9ad141a 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -30,7 +30,6 @@ anyhow = { workspace = true } camino = { workspace = true, features = ["serde1"] } canon-json = { workspace = true } cap-std-ext = { workspace = true, features = ["fs_utf8"] } -cfg-if = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive","cargo"] } clap_complete = "4" @@ -47,7 +46,6 @@ indoc = { workspace = true } libc = { workspace = true } openssl = { workspace = true } percent-encoding = { workspace = true } -regex = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 265fd58c4a..901f3ebdfa 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -98,6 +98,7 @@ use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::bootc_composefs::boot_partition::BootPartition; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::bootc_composefs::status::build_composefs_karg; use crate::bootc_kargs::compute_new_kargs; @@ -106,7 +107,6 @@ use crate::composefs_consts::{ }; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; use crate::spec::BootloaderKind; -use crate::store::find_booted_from_esp; use crate::task::Task; use crate::{ bootc_composefs::repo::open_composefs_repo, @@ -781,7 +781,7 @@ pub(crate) fn setup_composefs_bls_boot( ) -> Result { let id_hex = id.to_hex(); - let (esp_device, mut cmdline_refs, bootloader) = match setup_type { + let (boot_partition, mut cmdline_refs, bootloader) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { // root_setup.kargs has [root=UUID=, "rw"] let mut cmdline_options = Cmdline::new(); @@ -821,7 +821,7 @@ pub(crate) fn setup_composefs_bls_boot( let esp_part = root_setup.device_info.find_first_colocated_esp()?; ( - esp_part.path(), + BootPartition::for_install(esp_part.path(), postfetch.detected_bootloader)?, cmdline_options, postfetch.detected_bootloader.clone(), ) @@ -854,7 +854,7 @@ pub(crate) fn setup_composefs_bls_boot( )?; ( - find_booted_from_esp(&storage.physical_root)?, + storage.require_boot_partition()?.clone(), cmdline, bootloader, ) @@ -890,7 +890,8 @@ pub(crate) fn setup_composefs_bls_boot( } BootloaderKind::BLSCompatible => { - let efi_mount = mount_esp_writable(&esp_device).context("Mounting ESP")?; + let efi_mount = + mount_esp_writable(boot_partition.device()).context("Mounting boot partition")?; let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?); let efi_linux_dir = mounted_efi.join(EFI_LINUX); @@ -1621,7 +1622,7 @@ fn write_grub_uki_menuentry( setup_type: &BootSetupType, boot_label: String, id: &Sha512HashValue, - esp_device: &String, + esp_device: &str, ) -> Result<()> { let boot_dir = setup_type.grub_boot_path()?; create_dir_all(&boot_dir).context("Failed to create boot dir")?; @@ -1672,7 +1673,7 @@ fn write_grub_uki_menuentry( // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there // This will be sourced by grub2/user.cfg to be used for `--fs-uuid` let esp_uuid = Task::new("blkid for ESP UUID", "blkid") - .args(["-s", "UUID", "-o", "value", &esp_device]) + .args(["-s", "UUID", "-o", "value", esp_device]) .read()?; grub_dir.atomic_write( @@ -1702,6 +1703,7 @@ fn write_grub_uki_menuentry( #[context("Writing systemd UKI config")] fn write_systemd_uki_config( esp_dir: &Dir, + boot_partition: &BootPartition, setup_type: &BootSetupType, boot_label: String, version: Option, @@ -1759,8 +1761,10 @@ fn write_systemd_uki_config( )?; } - // Write the timeout for bootloader menu if not exists - if !esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) { + // Write the timeout for bootloader menu if not exists. systemd-boot only + // reads it from the ESP. + if matches!(boot_partition, BootPartition::Esp(_)) && !esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) + { esp_dir .atomic_write(SYSTEMD_LOADER_CONF_PATH, SYSTEMD_TIMEOUT) .with_context(|| format!("Writing to {SYSTEMD_LOADER_CONF_PATH}"))?; @@ -1782,7 +1786,7 @@ pub(crate) fn setup_composefs_uki_boot( boot_ids: &ExpectedBootImageIds, entries: Vec>, ) -> Result<(String, Sha512HashValue)> { - let (esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { + let (boot_partition, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { state.require_no_kargs_for_uki()?; @@ -1793,7 +1797,7 @@ pub(crate) fn setup_composefs_uki_boot( let esp_part = root_setup.device_info.find_first_colocated_esp()?; ( - esp_part.path(), + BootPartition::for_install(esp_part.path(), postfetch.detected_bootloader)?, postfetch.detected_bootloader.clone(), allow_missing_fsverity, state.composefs_options.uki_addon.as_ref(), @@ -1804,7 +1808,7 @@ pub(crate) fn setup_composefs_uki_boot( let bootloader = host.require_composefs_booted()?.bootloader.clone(); ( - find_booted_from_esp(&storage.physical_root)?, + storage.require_boot_partition()?.clone(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, // TODO: We never (re)install UKI addons on upgrade, only on initial @@ -1816,7 +1820,8 @@ pub(crate) fn setup_composefs_uki_boot( } }; - let esp_mount = mount_esp_writable(&esp_device).context("Mounting ESP")?; + let esp_mount = + mount_esp_writable(boot_partition.device()).context("Mounting boot partition")?; let mut uki_info: Option = None; @@ -1887,11 +1892,12 @@ pub(crate) fn setup_composefs_uki_boot( match bootloader.kind()? { BootloaderKind::GRUBClassic => { - write_grub_uki_menuentry(&setup_type, boot_label, &deploy_id, &esp_device)? + write_grub_uki_menuentry(&setup_type, boot_label, &deploy_id, boot_partition.device())? } BootloaderKind::BLSCompatible => write_systemd_uki_config( &esp_mount.fd, + &boot_partition, &setup_type, boot_label, version, @@ -1910,8 +1916,7 @@ pub(crate) fn setup_composefs_uki_boot( /// The composefs image (a detached `fsmount(2)` fd with no VFS path) is /// attached to a tmpdir via `move_mount(2)`, giving us a real filesystem path /// that `mount(2)` and bootctl can use. The ESP is mounted at -/// `/efi` (if that directory exists in the image) or `/boot`, -/// per the Boot Loader Specification. A tmpfs is also mounted at +/// `/boot`. A tmpfs is also mounted at /// `/tmp` to provide a writable scratch area for tools invoked with /// `--root`. /// @@ -1966,10 +1971,11 @@ impl MountedImageRoot { let composefs = TempMount::mount_fd(composefs_mnt_fd) .context("Attaching composefs image to temporary directory")?; - // TODO: support XBOOTLDR. Per BLS, the ESP should be mounted at /efi - // when a separate XBOOTLDR partition is present at /boot. bootc does - // not yet detect or use XBOOTLDR in the composefs install path, so - // unconditionally mount the ESP at /boot for now. + // The ESP goes on /boot even when there's an XBOOTLDR partition, + // where BLS would mount it on /efi: that would need an /efi + // directory in the image, and only the bootloader is installed + // through this mount, with the ESP's path passed explicitly. bootc + // writes the boot entries itself (see `BootPartition`). let esp_subdir = "boot"; // Mount a tmpfs over /tmp so that tools invoked with --root have a diff --git a/crates/lib/src/bootc_composefs/boot_partition.rs b/crates/lib/src/bootc_composefs/boot_partition.rs new file mode 100644 index 0000000000..3015445657 --- /dev/null +++ b/crates/lib/src/bootc_composefs/boot_partition.rs @@ -0,0 +1,149 @@ +//! Selecting the partition that holds the composefs boot entries. +//! +//! Per the Boot Loader Specification, boot entries go on the Extended Boot +//! Loader Partition (XBOOTLDR) on the ESP's disk if there is one, and on the +//! ESP otherwise. bootc follows that for bootloaders that read entries that +//! way (see [`Bootloader::is_bls_conformant`]), with one exception: systems +//! installed by a bootc that didn't use XBOOTLDR partitions keep their entries +//! on the ESP, so a booted system keeps using the partition holding the booted +//! deployment's entries. + +use anyhow::Result; +use bootc_mount::tempmount::TempMount; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; + +use crate::composefs_consts::{BLS_ENTRY_FILE_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; +use crate::parsers::bls_config::parse_bls_config; +use crate::spec::Bootloader; + +/// The partition holding UKIs, Type #1 entries and their kernels and +/// initramfs images. +#[derive(Debug, Clone)] +pub(crate) enum BootPartition { + /// The EFI System Partition + Esp(String), + /// An Extended Boot Loader Partition on the ESP's disk. It holds UKIs + /// that don't fit on a small ESP, such as one shared with Windows. + Xbootldr(String), +} + +impl BootPartition { + /// The partition to install entries to, given the ESP at `esp`: the VFAT + /// XBOOTLDR partition next to it if `bootloader` reads one and there is + /// one, and the ESP otherwise. + pub(crate) fn for_install(esp: String, bootloader: Bootloader) -> Result { + Ok(match sibling_xbootldr(&esp, bootloader)? { + Some(xbootldr) => Self::Xbootldr(xbootldr), + None => Self::Esp(esp), + }) + } + + /// Find and mount, with `mount`, the partition holding the entries of + /// the booted deployment `booted_verity`, given the ESP at `esp` it was + /// booted from. + pub(crate) fn mount_booted( + esp: String, + bootloader: Bootloader, + booted_verity: &str, + mount: impl Fn(&str) -> Result, + ) -> Result<(Self, TempMount)> { + if let Some(xbootldr) = sibling_xbootldr(&esp, bootloader)? { + let mounted = mount(&xbootldr)?; + if has_type1_entry_for(&mounted.fd, booted_verity)? { + return Ok((Self::Xbootldr(xbootldr), mounted)); + } + tracing::debug!( + "The booted deployment has no entry on XBOOTLDR partition {xbootldr}, using {esp}" + ); + } + let mounted = mount(&esp)?; + Ok((Self::Esp(esp), mounted)) + } + + /// The partition's device path + pub(crate) fn device(&self) -> &str { + match self { + Self::Esp(device) | Self::Xbootldr(device) => device, + } + } +} + +fn sibling_xbootldr(esp: &str, bootloader: Bootloader) -> Result> { + if !bootloader.is_bls_conformant() { + return Ok(None); + } + let xbootldr = bootc_blockdev::list_dev(Utf8Path::new(esp))?.find_sibling_xbootldr()?; + Ok(xbootldr.map(|xbootldr| { + tracing::debug!("Found XBOOTLDR partition {} next to {esp}", xbootldr.path()); + xbootldr.path() + })) +} + +/// Whether `boot_dir` has one of our Type #1 entries, staged or not, for the +/// deployment `verity`. +fn has_type1_entry_for(boot_dir: &Dir, verity: &str) -> Result { + for path in [TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED] { + let Some(entries) = boot_dir.open_dir_optional(path)? else { + continue; + }; + for entry in entries.entries_utf8()? { + let name = entry?.file_name()?; + if !(name.starts_with(BLS_ENTRY_FILE_PREFIX) && name.ends_with(".conf")) { + continue; + } + let entry = parse_bls_config(&entries.read_to_string(&name)?)?; + if entry.get_verity().is_ok_and(|v| v == verity) { + return Ok(true); + } + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutils::{ChangeType, TestRoot, fake_digest_version}; + + #[test] + fn test_has_type1_entry_for() -> Result<()> { + let mut root = TestRoot::new()?; + let installed = root.current().verity.clone(); + root.upgrade(1, ChangeType::Kernel)?; + let upgraded = root.current().verity.clone(); + let boot_dir = root.boot_dir()?; + + assert!(has_type1_entry_for(&boot_dir, &installed)?); + assert!(has_type1_entry_for(&boot_dir, &upgraded)?); + assert!(!has_type1_entry_for(&boot_dir, &fake_digest_version(2))?); + Ok(()) + } + + #[test] + fn test_has_type1_entry_for_staged() -> Result<()> { + let root = TestRoot::new()?; + let verity = root.current().verity.clone(); + let boot_dir = root.boot_dir()?; + boot_dir.rename(TYPE1_ENT_PATH, &boot_dir, TYPE1_ENT_PATH_STAGED)?; + + assert!(has_type1_entry_for(&boot_dir, &verity)?); + Ok(()) + } + + #[test] + fn test_has_type1_entry_for_other_os() -> Result<()> { + let dir = cap_std_ext::cap_tempfile::tempdir(cap_std_ext::cap_std::ambient_authority())?; + let verity = fake_digest_version(0); + assert!(!has_type1_entry_for(&dir, &verity)?); + + dir.create_dir_all(TYPE1_ENT_PATH)?; + dir.atomic_write( + format!("{TYPE1_ENT_PATH}/other-os.conf"), + "title Other\nlinux /vmlinuz\noptions root=UUID=other rw\n", + )?; + assert!(!has_type1_entry_for(&dir, &verity)?); + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index fe5bc9c6a5..ff5e7b9d8e 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod backwards_compat; pub(crate) mod boot; +pub(crate) mod boot_partition; pub(crate) mod delete; pub(crate) mod digest; pub(crate) mod export; diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 08a208d281..66caf5eb65 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -284,7 +284,8 @@ pub(crate) fn install_systemd_boot( root_path, "--esp-path", esp_path_in_root.as_str(), - // If we supported XBOOTLDR in the future, that'd go here with --boot-path. + // No --boot-path: bootc writes the boot entries itself, to an + // XBOOTLDR partition if there is one (see `BootPartition`). ]; if configopts.generic_image { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 03eeb22172..63e7df3748 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -82,7 +82,7 @@ //! - **Future extensibility**: Enables systemd-repart for declarative partition //! management //! -//! See [`crate::discoverable_partition_specification`] for the partition type GUIDs. +//! See [`bootc_blockdev::dps`] for the partition type GUIDs. //! //! ## Installation Flow //! diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index d45688a596..a20da260bd 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -33,8 +33,8 @@ use super::RootSetup; use super::State; use super::config::Filesystem; use crate::bootloader::systemd_version; -use crate::discoverable_partition_specification::BIOS_BOOT; use crate::task::Task; +use bootc_blockdev::dps::BIOS_BOOT; #[cfg(feature = "install-to-disk")] use bootc_mount::is_mounted_in_pid1_mountns; use linux_kernel_cmdline::utf8::Cmdline; @@ -565,7 +565,7 @@ fn sfdisk( } let esp_partno = if super::ARCH_USES_EFI { - let esp_guid = crate::discoverable_partition_specification::ESP; + let esp_guid = bootc_blockdev::dps::ESP; partno += 1; let esp_size = if composefs_backend { @@ -600,8 +600,7 @@ fn sfdisk( let root_size = root_size .map(|v| Cow::Owned(format!("size={v}MiB, "))) .unwrap_or_else(|| Cow::Borrowed("")); - let rootpart_uuid = - uuid::Uuid::parse_str(crate::discoverable_partition_specification::this_arch_root())?; + let rootpart_uuid = uuid::Uuid::parse_str(bootc_blockdev::dps::this_arch_root())?; writeln!( &mut partitioning_buf, r#"{root_size}type={rootpart_uuid}, name="root""# @@ -759,7 +758,7 @@ pub(crate) fn install_create_rootfs( let root_device = device.find_device_by_partno(layout.rootpn)?; // Verify the partition type matches the DPS root partition type for this architecture - let expected_parttype = crate::discoverable_partition_specification::this_arch_root(); + let expected_parttype = bootc_blockdev::dps::this_arch_root(); if !root_device .parttype .as_ref() diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index edd50e347b..47f80a7997 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -72,7 +72,6 @@ mod composefs_consts; mod container_export; mod containerenv; pub(crate) mod deploy; -mod discoverable_partition_specification; pub(crate) mod fsck; pub(crate) mod generator; mod glyph; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index b58ed0ef18..478614d9cb 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -292,6 +292,14 @@ impl Bootloader { Bootloader::None => anyhow::bail!("Bootloader was None"), } } + + /// Whether the bootloader reads boot entries from where the Boot Loader + /// Specification puts them: the XBOOTLDR partition next to the ESP if + /// there is one, and the ESP otherwise. grub-cc isn't known to read + /// XBOOTLDR partitions yet, so it only gets entries on the ESP. + pub(crate) fn is_bls_conformant(&self) -> bool { + matches!(self, Bootloader::Systemd) + } } /// A bootable entry diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index e517bc9637..6564000cd5 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -118,6 +118,7 @@ use composefs_ctl::composefs; use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; use crate::bootc_composefs::boot::{EFI_LINUX, mount_esp, mount_esp_readonly, mount_esp_writable}; +use crate::bootc_composefs::boot_partition::BootPartition; use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader}; use crate::composefs_consts::{BLS_ENTRY_FILE_PREFIX, TYPE1_ENT_PATH}; use crate::install::BOOT; @@ -388,7 +389,7 @@ impl GrubBootLocation { /// Decide from the partition type and filesystem type of the filesystem /// mounted on `/boot`, and whether it has a GRUB directory. fn classify(parttype: Option<&str>, fstype: &str, has_grub_dir: bool) -> Self { - use crate::discoverable_partition_specification as dps; + use bootc_blockdev::dps; // The ESP mounted on /boot: GRUB's configuration is in /sysroot/boot let is_esp = match parttype { @@ -596,22 +597,25 @@ impl BootedStorage { // respective ESP device UUID // // Both mount variants transparently reuse an already-mounted - // ESP when present (e.g. auto-mounted at /boot ro via + // partition when present (e.g. auto-mounted at /boot ro via // `systemd.mount-extra` in the deployment cmdline). - let esp_path = find_booted_from_esp(&physical_root)?; - - let esp_mount = match esp_access { - EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?, - EspAccess::ReadWrite => mount_esp_writable(&esp_path)?, - }; - - let (boot_dir, grub_boot_path) = match get_bootloader()?.kind()? { + let bootloader = get_bootloader()?; + let (boot_partition, esp_mount) = BootPartition::mount_booted( + find_booted_from_esp(&physical_root)?, + bootloader, + &cmdline.digest, + |device| match esp_access { + EspAccess::ReadOnly => mount_esp_readonly(device), + EspAccess::ReadWrite => mount_esp_writable(device), + }, + )?; + + let (boot_dir, grub_boot_path) = match bootloader.kind()? { // We can have a separate /boot and not /sysroot/boot BootloaderKind::GRUBClassic => { let (dir, path) = get_boot_dir_for_grub(&physical_root)?; (dir, Some(path)) } - // NOTE: Handle XBOOTLDR partitions here if and when we use it BootloaderKind::BLSCompatible => { (esp_mount.fd.try_clone().context("Cloning fd")?, None) } @@ -625,6 +629,7 @@ impl BootedStorage { boot_dir: Some(boot_dir), grub_boot_path, esp: Some(esp_mount), + boot_partition: Some(boot_partition), ostree: Default::default(), composefs: OnceCell::from(composefs.clone()), imgstore: Default::default(), @@ -671,6 +676,7 @@ impl BootedStorage { boot_dir: None, grub_boot_path: None, esp: None, + boot_partition: None, ostree: OnceCell::from(sysroot), composefs: Default::default(), imgstore: Default::default(), @@ -725,15 +731,19 @@ pub(crate) struct Storage { /// The 'boot' directory, useful and `Some` only for composefs systems /// For grub booted systems, this points to `/sysroot/boot`, or to `/boot` /// if that's a separate boot partition - /// For systemd booted systems, this points to the ESP + /// For systemd booted systems, this points to `boot_partition` pub boot_dir: Option, /// The absolute path of `boot_dir` on composefs systems booted with grub grub_boot_path: Option, - /// The ESP mounted at a tmp location + /// `boot_partition` mounted at a tmp location pub esp: Option, + /// The partition holding the UKIs and Type #1 entries on composefs + /// systems: the ESP, or the XBOOTLDR partition next to it + boot_partition: Option, + /// Our runtime state run: Dir, @@ -797,6 +807,7 @@ impl Storage { boot_dir: None, grub_boot_path: None, esp: None, + boot_partition: None, ostree: ostree_cell, composefs: Default::default(), imgstore: Default::default(), @@ -836,6 +847,13 @@ impl Storage { .ok_or_else(|| anyhow::anyhow!("ESP not found")) } + /// Returns the partition holding the UKIs and Type #1 entries + pub(crate) fn require_boot_partition(&self) -> Result<&BootPartition> { + self.boot_partition + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Boot partition not found")) + } + /// Returns the Directory where the Type1 boot binaries are stored /// The grub boot directory (see `require_boot_dir`) for Grub, and ESP/EFI/Linux for systemd-boot pub(crate) fn bls_boot_binaries_dir(&self) -> Result { @@ -1066,8 +1084,8 @@ mod tests { #[test] fn test_grub_boot_location_classify() { - use crate::discoverable_partition_specification as dps; use GrubBootLocation::*; + use bootc_blockdev::dps; const LINUX_DATA: &str = "0fc63daf-8483-4772-8e79-3d69d8477de4"; let upper_xbootldr = dps::XBOOTLDR.to_ascii_uppercase(); diff --git a/docs/src/bootc-bootloaders.7.md b/docs/src/bootc-bootloaders.7.md index 6f88aec2fb..3e12eafd85 100644 --- a/docs/src/bootc-bootloaders.7.md +++ b/docs/src/bootc-bootloaders.7.md @@ -35,3 +35,7 @@ NOTE: none is only supported for the Ostree backend and not for Composefs. It is Whenever the container image has a UKI, bootc automatically selects the composefs backend during installation. The [prerequisites for sealed images](building/bootc-sealed-images.7.md#prerequisites) describe the currently-supported UKI + systemd-boot configuration. Note that having a UKI does not by itself make an install sealed — that also depends on whether [fs-verity enforcement](bootc-composefs.7.md#overview) is on. Composefs installs using a traditional `vmlinuz`/`initramfs.img` layout instead of a UKI can enforce fs-verity, but are never sealed, since nothing authenticates the root digest. They can use either `bootupd` (GRUB) or systemd-boot. Under the hood, bootc writes standard BLS boot entries for both UKI and traditional kernels; see the [composefs boot module documentation](https://github.com/bootc-dev/bootc/blob/main/crates/lib/src/bootc_composefs/boot.rs) for details on how entry filenames and sort-keys are chosen to sort correctly on both GRUB and systemd-boot. + +### XBOOTLDR partitions + +With systemd-boot, if the ESP's disk also has an [Extended Boot Loader Partition](https://uapi-group.org/specifications/specs/boot_loader_specification/#the-partitions) (XBOOTLDR) formatted as VFAT, bootc writes the UKIs, the BLS entries and their kernels and initramfs images there, and only the bootloader goes on the ESP. This is useful when the ESP is too small to hold the UKIs of the booted, staged and rollback deployments, which is common on disks shared with Windows. The partition has to exist when installing; bootc uses it from then on for upgrades, rollbacks and garbage collection. A system whose boot entries are on the ESP keeps them there, even if an XBOOTLDR partition is added later. diff --git a/docs/src/bootc-composefs.7.md b/docs/src/bootc-composefs.7.md index 66c556c0c4..d48ecca7df 100644 --- a/docs/src/bootc-composefs.7.md +++ b/docs/src/bootc-composefs.7.md @@ -142,8 +142,9 @@ and may change or be removed: - `--soft-reboot=auto` doesn't fall back to a regular reboot when the new deployment can't be soft rebooted into; see [Soft reboots](bootc-upgrades.7.md#soft-reboots). -- Only a single ESP is used (the first one found), and a separate - XBOOTLDR partition is not supported. +- Only a single ESP is used (the first one found). A separate XBOOTLDR + partition is only used with systemd-boot; see + [Bootloaders](bootc-bootloaders.7.md#xbootldr-partitions). - Rollback with GRUB and UKIs assumes exactly two deployments. - `bootc internals fsck` doesn't yet comprehensively cover the composefs backend; see [#2497](https://github.com/bootc-dev/bootc/pull/2497). diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 90e02dc28b..1b1e84ff04 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -367,4 +367,16 @@ execute: test: - /tmt/tests/tests/test-60-install-composefs-native extra-fixme_skip_if_uki: true + +/plan-61-composefs-xbootldr: + summary: Test composefs with systemd-boot and an XBOOTLDR partition + discover: + how: fmf + test: + - /tmt/tests/tests/test-61-composefs-xbootldr + adjust: + - when: seal_state == sealed + enabled: false + because: the derived images are built in the VM, which has no keys to sign their UKIs for Secure Boot + extra-skip_if_ostree: true # END GENERATED PLANS diff --git a/tmt/tests/booted/test-composefs-xbootldr.nu b/tmt/tests/booted/test-composefs-xbootldr.nu new file mode 100644 index 0000000000..15691f756b --- /dev/null +++ b/tmt/tests/booted/test-composefs-xbootldr.nu @@ -0,0 +1,297 @@ +# number: 61 +# tmt: +# summary: Test composefs with systemd-boot and an XBOOTLDR partition +# duration: 60m +# require: +# - dosfstools +# adjust: +# - when: seal_state == sealed +# enabled: false +# because: the derived images are built in the VM, which has no keys to sign their UKIs for Secure Boot +# extra: +# skip_if_ostree: true +# +# With systemd-boot, bootc writes the UKIs, the BLS entries and their kernels +# to a VFAT XBOOTLDR partition next to the ESP when there is one, so that a +# small ESP (e.g. Windows' 100 MB one) only holds the bootloader. Systems +# whose entries are on the ESP, e.g. from a bootc that didn't use XBOOTLDR +# partitions, keep them there. +# +# Booting a disk installed inside the VM isn't possible here, so the VM's own +# disk is given that layout instead: +# +# 1. Split the ESP in half, and put an empty VFAT XBOOTLDR partition in the +# second half. bootc's entries, UKIs and kernels stay on the ESP. +# 2. Switch to a derived image: since the booted deployment's entries are on +# the ESP, its entries and binaries go there too. +# 3. Move bootc's files to the XBOOTLDR partition, as if it had been +# installed there, and upgrade to a newer build of that image, which goes +# back to the base image's initramfs: GC must keep that one, and drop +# nothing in use. +# 4. Roll back. +# 5. The rollback worked, and bootc wrote nothing to the ESP. + +use std assert +use tap.nu + +const XBOOTLDR_TYPE = "BC13C2FF-59E6-4262-A352-B275FD6F7172" +const ESP_TYPE = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" +const SECTOR_SIZE = 512 +const STATE_FILE = "/var/xbootldr-test.json" +const DERIVED_IMAGE = "localhost/bootc-xbootldr-derived" +const MARKER = "/usr/share/bootc-xbootldr-marker" +const BOOTC_PREFIX = "bootc_" +# What bootc owns on the partition holding the boot entries +const BOOTC_GLOBS = ["loader/entries*/bootc_*.conf" "EFI/Linux/bootc*"] +# Keys of BLS entries that refer to files on the same partition +const ENTRY_FILE_KEYS = ["linux" "initrd" "efi" "uki"] + +def partitions [disk: string] { + (sfdisk --json $disk | from json).partitiontable.partitions +} + +def partition_of_type [disk: string, type: string] { + partitions $disk | where { |p| ($p.type | str upcase) == $type } | first +} + +def partition_number [node: string] { + $node | str replace -r '.*[^0-9]' '' | into int +} + +# The path of an existing mount of `dev`, or a new one. +def find_or_mount [dev: string, name: string] { + let existing = (findmnt -rn -o TARGET -S $dev | lines) + if ($existing | is-not-empty) { + return ($existing | first) + } + let path = $"/var/mnt/($name)" + mkdir $path + mount $dev $path + $path +} + +def bootc_files [dir: string] { + $BOOTC_GLOBS | each { |g| glob $"($dir)/($g)" } | flatten +} + +# The files the BLS entries refer to, relative to the partition they're on. +def referenced_files [dir: string] { + glob $"($dir)/loader/entries*/*.conf" + | each { |f| open $f | lines | parse -r '^(?\S+)\s+(?\S+)' } + | flatten + | where key in $ENTRY_FILE_KEYS + | get path + | uniq +} + +# The UKIs and kernel directories bootc keeps on `dir`, as entries refer to +# them. +def bootc_binaries [dir: string] { + let ukis = (glob $"($dir)/EFI/Linux/bootc/($BOOTC_PREFIX)*.efi") + let kernel_dirs = (glob $"($dir)/EFI/Linux/($BOOTC_PREFIX)*" --no-file) + $ukis | append $kernel_dirs | each { |p| $p | str replace $dir "" } +} + +# The mounted ESP and XBOOTLDR partition +def mounted_partitions [] { + let state = (open $STATE_FILE) + { esp: (find_or_mount $state.esp "esp"), xbootldr: (find_or_mount $state.xbootldr "xbootldr") } +} + +# Check that the entries, UKIs and kernels are all on the `holder` partition +# ("esp" or "xbootldr"), that GC left nothing unreferenced there, and that +# nothing of bootc's is on the other one. +def assert_layout [holder: string] { + let mounted = (mounted_partitions) + let dir = ($mounted | get $holder) + let other = ($mounted | reject $holder | values | first) + + let entries = (glob $"($dir)/loader/entries/*.conf") + print $"BLS entries on the ($holder) partition: ($entries)" + assert ($entries | is-not-empty) $"No BLS entries on the ($holder) partition" + + let referenced = (referenced_files $dir) + print $"Referenced files: ($referenced)" + for f in $referenced { + assert ($"($dir)($f)" | path exists) $"($f) is not on the ($holder) partition" + } + let referenced_binaries = ($referenced | each { |f| + if ($f | str ends-with ".efi") { $f } else { $f | path dirname } + } | uniq) + let on_disk = (bootc_binaries $dir) + print $"Binaries on the ($holder) partition: ($on_disk)" + assert equal ($on_disk | sort) ($referenced_binaries | sort) $"Binaries on the ($holder) partition don't match the BLS entries" + + let elsewhere = (bootc_files $other) + assert ($elsewhere | is-empty) $"bootc files outside the ($holder) partition: ($elsewhere)" + assert ($"($mounted.esp)/loader/loader.conf" | path exists) "loader.conf is missing from the ESP" + assert (not ($"($mounted.xbootldr)/loader/loader.conf" | path exists)) "loader.conf was written to the XBOOTLDR partition" +} + +def move_to_xbootldr [] { + let mounted = (mounted_partitions) + for f in (bootc_files $mounted.esp) { + let dest = $"($mounted.xbootldr)($f | str replace $mounted.esp "")" + mkdir ($dest | path dirname) + ^mv $f $dest + } + sync +} + +# Split the ESP in half, with a new, empty XBOOTLDR partition in the second +# half. +def make_xbootldr [] { + let root_part = (findmnt -n -o SOURCE /sysroot | str trim) + let disk = $"/dev/(lsblk -n -o PKNAME $root_part | str trim)" + let esp = (partition_of_type $disk $ESP_TYPE) + let esp_nr = (partition_number $esp.node) + let esp_uuid = (blkid -s UUID -o value $esp.node | str trim) + let esp_partuuid = (blkid -s PARTUUID -o value $esp.node | str trim) + print $"Disk ($disk), ESP ($esp.node) \(($esp.size) sectors\)" + let new_esp_sectors = ($esp.size / 2 / 2048 | math floor) * 2048 + assert (($new_esp_sectors * $SECTOR_SIZE) >= 512 * 1024 * 1024) "ESP too small to split" + + let esp_backup = "/var/tmp/esp-backup" + /usr/bin/cp -a -T (find_or_mount $esp.node "esp") $esp_backup + for unit in [boot.automount efi.automount boot.mount efi.mount] { + do { systemctl stop $unit } | complete | ignore + } + for t in (findmnt -rn -o TARGET -S $esp.node | lines) { + umount $t + } + + let xbootldr_start = $esp.start + $new_esp_sectors + let xbootldr_sectors = $esp.size - $new_esp_sectors + let used_nrs = (partitions $disk | each { |p| partition_number $p.node }) + let xbootldr_nr = (1..128 | where { |n| $n not-in $used_nrs } | first) + # See test-composefs-separate-boot.nu: the root filesystem is mounted, so + # update the kernel's partitions one at a time while holding the disk's + # lock against systemd-udevd. + let script = ' + set -euo pipefail + disk=$1 esp_nr=$2 esp_spec=$3 xbootldr_nr=$4 xbootldr_spec=$5 + echo "$esp_spec" | sfdisk --lock=no --no-reread -N "$esp_nr" "$disk" + echo "$xbootldr_spec" | sfdisk --lock=no --no-reread -N "$xbootldr_nr" "$disk" + partx -u --nr "$esp_nr" "$disk" + partx -a --nr "$xbootldr_nr" "$disk" + ' + (udevadm lock $"--device=($disk)" bash -c $script bash $disk $esp_nr + $"start=($esp.start), size=($new_esp_sectors), type=($ESP_TYPE)" + $xbootldr_nr $"start=($xbootldr_start), size=($xbootldr_sectors), type=($XBOOTLDR_TYPE)") + udevadm settle + let xbootldr = (partitions $disk | where start == $xbootldr_start | first).node + for p in [[node sectors]; [$esp.node $new_esp_sectors] [$xbootldr $xbootldr_sectors]] { + let sysfs = $"/sys/class/block/($p.node | path basename)/size" + assert ($sysfs | path exists) $"The kernel doesn't know ($p.node)" + assert equal (open --raw $sysfs | str trim | into int) $p.sectors $"The kernel's size of ($p.node)" + } + # systemd-boot reports the ESP it was started from by its PARTUUID, which + # bootc looks the XBOOTLDR partition up from + assert equal (blkid -s PARTUUID -o value $esp.node | str trim) $esp_partuuid "The ESP's PARTUUID changed" + print $"New ESP ($esp.node), XBOOTLDR ($xbootldr)" + + # Keep the ESP's filesystem UUID, in case something refers to it + mkfs.vfat -F 32 -n EFI-SYSTEM -i ($esp_uuid | str replace "-" "") $esp.node + mkfs.vfat -F 32 -n XBOOTLDR $xbootldr + udevadm settle + + /usr/bin/cp -r -T $esp_backup (find_or_mount $esp.node "esp") + rm -rf $esp_backup + + { esp: $esp.node, xbootldr: $xbootldr } | to json | save -f $STATE_FILE + sync +} + +# A derived image with a marker. With `own_initramfs`, a traditional +# kernel install gets its own copy of the initramfs (zero padding is ignored +# when unpacking it), so GC has a kernel directory to drop. +def build_derived [marker: string, --own-initramfs] { + let initramfs_step = if $own_initramfs { + "RUN for f in /usr/lib/modules/*/initramfs.img; do if test -f $f; then head -c 512 /dev/zero >> $f; fi; done" + } else { + "" + } + let containerfile = (tap make_uki_containerfile $" + FROM localhost/bootc as base + ($initramfs_step) + RUN echo ($marker) > ($MARKER) + ") + $containerfile | podman build -t $DERIVED_IMAGE -f - . +} + +def assert_marker [expected: string] { + assert ($MARKER | path exists) "Not booted into the derived image" + assert equal (open $MARKER | str trim) $expected +} + +def assert_staged_on [holder: string] { + let dir = (mounted_partitions | get $holder) + assert (glob $"($dir)/loader/entries.staged/*.conf" | is-not-empty) $"No staged entries on the ($holder) partition" +} + +def first_boot [] { + tap begin "composefs with systemd-boot and an XBOOTLDR partition" + + let bootloader = ((bootc status --json | from json).status.booted.composefs.bootloader | str downcase) + if $bootloader != "systemd" { + print $"Only systemd-boot uses an XBOOTLDR partition, not ($bootloader); skipping" + tap ok + return + } + + make_xbootldr + tmt-reboot +} + +def second_boot [] { + assert_layout esp + bootc status + + bootc image copy-to-storage + build_derived v1 --own-initramfs + bootc switch --transport containers-storage $DERIVED_IMAGE + assert_staged_on esp + assert_layout esp + tmt-reboot +} + +def third_boot [] { + assert_marker v1 + assert_layout esp + + move_to_xbootldr + assert_layout xbootldr + bootc status + + build_derived v2 + bootc upgrade + assert_staged_on xbootldr + tmt-reboot +} + +def fourth_boot [] { + assert_marker v2 + assert_layout xbootldr + + bootc rollback + assert (bootc status --json | from json).status.rollbackQueued + tmt-reboot +} + +def fifth_boot [] { + assert_marker v1 + assert_layout xbootldr + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, + "4" => fifth_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 28300bfebe..b1da18c13f 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -233,3 +233,14 @@ check: summary: Test that composefs-native images default to the composefs backend duration: 45m test: nu booted/test-install-composefs-native.nu + +/test-61-composefs-xbootldr: + summary: Test composefs with systemd-boot and an XBOOTLDR partition + duration: 60m + require: + - dosfstools + adjust: + - when: seal_state == sealed + enabled: false + because: the derived images are built in the VM, which has no keys to sign their UKIs for Secure Boot + test: nu booted/test-composefs-xbootldr.nu