diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 56f38ca804..12bffdc4ea 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -82,6 +82,18 @@ 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"; +/// Extended Boot Loader Partition (XBOOTLDR) type GUID for GPT; see the +/// [Boot Loader Specification](https://uapi-group.org/specifications/specs/boot_loader_specification/). +pub const XBOOTLDR: &str = "bc13c2ff-59e6-4262-a352-b275fd6f7172"; + +/// MBR partition type ID of the XBOOTLDR partition. +pub const XBOOTLDR_ID_MBR: u8 = 0xEA; + +/// Parse an MBR partition type as reported by lsblk, e.g. "0xef". +fn parse_mbr_parttype(pt: &str) -> Option { + u8::from_str_radix(pt.strip_prefix("0x").unwrap_or(pt), 16).ok() +} + #[derive(Debug, Deserialize)] struct DevicesOutput { blockdevices: Vec, @@ -206,15 +218,42 @@ impl Device { Ok((!esps.is_empty()).then_some(esps)) } - /// Find a single ESP partition among all root devices backing this device. + /// Find a single ESP partition among all root devices backing this device, + /// and the XBOOTLDR partition next to it, if there is one. /// /// 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 + /// partitions on each, and 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 { - self.find_colocated_esps()? - .and_then(|mut v| Some(v.remove(0))) - .ok_or_else(|| anyhow!("No ESP partition found among backing devices")) + /// + /// Per the Boot Loader Specification, the XBOOTLDR partition must be on + /// the same disk as the ESP (systemd-boot only looks for it there), so an + /// XBOOTLDR partition on another disk is ignored. + pub fn find_first_colocated_esp_and_xbootldr(&self) -> Result<(Device, Option)> { + for root in self.find_all_roots()? { + if let Some(esp) = root.find_partition_of_esp_optional()? { + let xbootldr = root.find_xbootldr_next_to(esp).cloned(); + return Ok((esp.clone(), xbootldr)); + } + } + Err(anyhow!("No ESP partition found among backing devices")) + } + + /// Find the XBOOTLDR partition (GPT or MBR) in the same partition table as `esp`, + /// recursing like [`Self::find_partition_of_esp_optional`] does (e.g. + /// into a firmware RAID array). + pub fn find_xbootldr_next_to(&self, esp: &Device) -> Option<&Device> { + let children = self.children.as_ref()?; + if children.iter().any(|child| child.name == esp.name) { + return match self.pttype.as_deref() { + Some("dos") => children.iter().find(|child| { + child.parttype.as_deref().and_then(parse_mbr_parttype) == Some(XBOOTLDR_ID_MBR) + }), + _ => self.find_partition_of_type(XBOOTLDR), + }; + } + children + .iter() + .find_map(|child| child.find_xbootldr_next_to(esp)) } /// Find all BIOS boot partitions across all root devices backing this device. @@ -260,11 +299,8 @@ impl Device { 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() - }) + .as_deref() + .and_then(parse_mbr_parttype) .is_some_and(|pt| ESP_ID_MBR.contains(&pt)) }), // When pttype is None (e.g. older lsblk or partition devices), default @@ -358,7 +394,9 @@ impl Device { // When udev is unavailable, lsblk can't populate parttype/pttype from // the udev database. Fall back to blkid -p which probes the disk // directly. See https://github.com/osbuild/osbuild/pull/2428 - if !have_udev() && (self.parttype.is_none() || self.pttype.is_none()) { + if !have_udev() + && (self.parttype.is_none() || self.pttype.is_none() || self.fstype.is_none()) + { let props = blkid_probe(&self.path())?; if self.parttype.is_none() { self.parttype = props.get("PART_ENTRY_TYPE").cloned(); @@ -366,6 +404,9 @@ impl Device { if self.pttype.is_none() { self.pttype = props.get("PTTYPE").cloned(); } + if self.fstype.is_none() { + self.fstype = props.get("TYPE").cloned(); + } } // Recurse to child devices for child in self.children.iter_mut().flatten() { @@ -863,8 +904,20 @@ mod test { assert_eq!(esp.partn, Some(1)); } + /// Linux root (x86-64) partition type GUID, per the Discoverable Partitions Specification. + const ROOT_X86_64: &str = "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"; + /// Helper to construct a minimal MBR disk Device with given child partition types. fn make_mbr_disk(parttypes: &[&str]) -> Device { + make_disk("dos", parttypes) + } + + /// Helper to construct a minimal GPT disk Device with given child partition types. + fn make_gpt_disk(parttypes: &[&str]) -> Device { + make_disk("gpt", parttypes) + } + + fn make_disk(pttype: &str, parttypes: &[&str]) -> Device { Device { name: "vda".into(), serial: None, @@ -880,7 +933,7 @@ mod test { fstype: None, uuid: None, path: Some("/dev/vda".into()), - pttype: Some("dos".into()), + pttype: Some(pttype.into()), ro: None, children: Some( parttypes @@ -901,7 +954,7 @@ mod test { fstype: None, uuid: None, path: None, - pttype: Some("dos".into()), + pttype: Some(pttype.into()), ro: None, children: None, }) @@ -973,6 +1026,51 @@ mod test { assert_eq!(md0.fstype.as_deref().unwrap(), "ext4"); } + #[test] + fn test_find_xbootldr_next_to() { + let cases: &[(&[&str], Option)] = &[ + // ESP and XBOOTLDR, in either order + (&[BIOS_BOOT, ESP, XBOOTLDR, ROOT_X86_64], Some(3)), + (&[XBOOTLDR, ESP, ROOT_X86_64], Some(1)), + // The XBOOTLDR type is matched case-insensitively, like the ESP's + (&[ESP, "BC13C2FF-59E6-4262-A352-B275FD6F7172"], Some(2)), + // No XBOOTLDR + (&[BIOS_BOOT, ESP, ROOT_X86_64], None), + ]; + for (parttypes, expected) in cases { + let dev = make_gpt_disk(parttypes); + let esp = dev.find_partition_of_esp().unwrap(); + let xbootldr = dev.find_xbootldr_next_to(esp).map(|d| d.partn.unwrap()); + assert_eq!(xbootldr, *expected, "{parttypes:?}"); + } + + // MBR: XBOOTLDR is type 0xea + let dev = make_mbr_disk(&["0xef", "0xea", "0x83"]); + let esp = dev.find_partition_of_esp().unwrap(); + assert_eq!(dev.find_xbootldr_next_to(esp).unwrap().partn, Some(2)); + let dev = make_mbr_disk(&["0xef", "0x83"]); + let esp = dev.find_partition_of_esp().unwrap(); + assert!(dev.find_xbootldr_next_to(esp).is_none()); + + // An XBOOTLDR partition on another disk isn't next to the ESP + let other = make_gpt_disk(&[ESP]); + let dev = make_gpt_disk(&[XBOOTLDR]); + let esp = other.find_partition_of_esp().unwrap(); + let esp = Device { + name: "vdb1".into(), + ..esp.clone() + }; + assert!(dev.find_xbootldr_next_to(&esp).is_none()); + + // Firmware RAID: the ESP is inside the md array, so is its XBOOTLDR + let fixture = include_str!("../tests/fixtures/lsblk-vroc.json"); + let devs: DevicesOutput = serde_json::from_str(fixture).unwrap(); + for nvme in &devs.blockdevices { + let esp = nvme.find_partition_of_esp().unwrap(); + assert!(nvme.find_xbootldr_next_to(esp).is_none()); + } + } + #[test] fn test_mbr_esp_detection() { // 0x06 (FAT16) is recognized as ESP diff --git a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs index bf203c0d41..25e4c2b5fc 100644 --- a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs +++ b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs @@ -215,7 +215,7 @@ fn stage_bls_entry_changes( } BLSConfigType::EFI { key, .. } => { - // boot_dir in case of UKI is the ESP + // boot_dir in case of UKI is the ESP, or XBOOTLDR plan_efi_binary_renames(&boot_dir, &digest, &mut rename_transaction)?; let new_path = Utf8PathBuf::from("/") .join(BOOTC_UKI_DIR) @@ -290,10 +290,10 @@ fn handle_bls_conf( create_staged_bls_entries(boot_dir, &new_bls_entries)?; let binaries_dir = if is_uki { - let esp = storage.require_esp()?; - let uki_dir = esp.fd.open_dir(BOOTC_UKI_DIR).context("Opening UKI dir")?; - - uki_dir + storage + .require_uki_partition()? + .open_dir(BOOTC_UKI_DIR) + .context("Opening UKI dir")? } else { storage.bls_boot_binaries_dir()? }; diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a10d41bf12..3df280aeb1 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -98,7 +98,7 @@ use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; +use crate::bootc_composefs::state::{find_bls_for_digest, get_booted_bls, write_composefs_state}; use crate::bootc_composefs::status::build_composefs_karg; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; @@ -391,7 +391,8 @@ fn mount_esp(device: &str) -> Result { TempMount::mount_dev(device, "vfat", ESP_MOUNT_FLAGS, Some(ESP_MOUNT_DATA)) } -/// Get a read-only view of the ESP for the provided device, gracefully +/// Get a read-only view of the ESP (or another FAT boot partition, such as +/// XBOOTLDR) for the provided device, gracefully /// handling the case where the ESP is already mounted in the root mount /// namespace (e.g. via `systemd.mount-extra=UUID=:/boot:auto:ro`). /// If the ESP is already mounted, that mount is cloned privately into a @@ -404,7 +405,8 @@ pub fn mount_esp_readonly(device: &str) -> Result { mount_esp(device) } -/// Get a read-write view of the ESP for the provided device, gracefully +/// Get a read-write view of the ESP (or another FAT boot partition, such as +/// XBOOTLDR) for the provided device, gracefully /// handling the case where the ESP is already mounted (possibly read-only) /// in the current mount namespace. /// @@ -453,6 +455,135 @@ pub(crate) fn mount_esp_at( ) } +/// The filesystem type an XBOOTLDR partition must have for bootc to use it. +/// +/// systemd-boot reads XBOOTLDR through the firmware's file system drivers, +/// which in practice only support FAT. +const XBOOTLDR_FSTYPE: &str = "vfat"; + +/// The partitions a systemd-boot system boots from. +/// +/// Per the Boot Loader Specification, `$BOOT` is the XBOOTLDR partition when +/// there is one, and the ESP otherwise. `$BOOT` holds the Type #1 entries, +/// the kernels and initrds and the UKIs, while the ESP always holds the boot +/// loader itself and `loader/loader.conf`. With GRUB, UKIs stay on the ESP +/// and Type #1 entries on `/boot`, so `xbootldr` is always `None`. +#[derive(Debug, Clone)] +pub(crate) struct BootPartitions { + /// The ESP's device path + pub(crate) esp: String, + /// The XBOOTLDR partition's device path, if bootc uses one + pub(crate) xbootldr: Option, +} + +impl BootPartitions { + /// Find the ESP on the disk(s) backing `dev`, and the XBOOTLDR partition + /// next to it if `bootloader` reads one and bootc can use it, for an + /// install. + #[context("Finding boot partitions")] + pub(crate) fn find(dev: &bootc_blockdev::Device, bootloader: &Bootloader) -> Result { + let (esp, xbootldr) = dev.find_first_colocated_esp_and_xbootldr()?; + Self::new(&esp, xbootldr, bootloader, tracing::Level::WARN) + } + + /// `unusable_log_level` is how loudly to say that an XBOOTLDR partition + /// can't be used. + fn new( + esp: &bootc_blockdev::Device, + xbootldr: Option, + bootloader: &Bootloader, + unusable_log_level: tracing::Level, + ) -> Result { + let xbootldr = match bootloader.kind()? { + BootloaderKind::BLSCompatible => { + xbootldr.filter(|d| xbootldr_is_usable(d, unusable_log_level)) + } + BootloaderKind::GRUBClassic => None, + }; + Ok(Self { + esp: esp.path(), + xbootldr: xbootldr.map(|d| d.path()), + }) + } + + /// Like [`Self::find`], for the booted system: an XBOOTLDR partition is + /// only used if it has the entry for the booted deployment (identified + /// by its composefs digest). Systems installed before bootc supported + /// XBOOTLDR keep their entries on the ESP, even if an unused XBOOTLDR + /// partition exists, and so does another install sharing the disk. + #[context("Finding boot partitions of the booted system")] + pub(crate) fn find_booted( + dev: &bootc_blockdev::Device, + bootloader: &Bootloader, + booted_digest: &str, + ) -> Result { + let (esp, xbootldr) = dev.find_first_colocated_esp_and_xbootldr()?; + // This runs for every command, so don't warn about the same + // unusable XBOOTLDR partition every time; the install did. + let mut parts = Self::new(&esp, xbootldr, bootloader, tracing::Level::DEBUG)?; + let Some(xbootldr) = parts.xbootldr.as_deref() else { + return Ok(parts); + }; + match has_entry_for(xbootldr, booted_digest) { + Ok(true) => { + if tracing::enabled!(tracing::Level::DEBUG) + && has_entry_for(&parts.esp, booted_digest).unwrap_or(false) + { + tracing::debug!( + "Both the ESP {} and XBOOTLDR {xbootldr} have an entry for the booted deployment; using XBOOTLDR", + parts.esp + ); + } + } + Ok(false) => { + tracing::debug!("No entry for the booted deployment on {xbootldr}, using the ESP"); + parts.xbootldr = None; + } + // Don't let an unrelated, broken XBOOTLDR partition break a + // system whose entries are on the ESP. + Err(e) => { + tracing::warn!("Not using XBOOTLDR partition {xbootldr}: {e:#}"); + parts.xbootldr = None; + } + } + Ok(parts) + } + + /// The device of `$BOOT`: where Type #1 entries and UKIs go. + pub(crate) fn boot(&self) -> &str { + self.xbootldr.as_deref().unwrap_or(&self.esp) + } +} + +/// Whether the FAT boot partition `device` has the Type #1 entry for the +/// deployment with the composefs digest `digest`. +#[context("Looking for boot entries on {device}")] +fn has_entry_for(device: &str, digest: &str) -> Result { + let mnt = mount_esp_readonly(device)?; + if !mnt.fd.try_exists(TYPE1_ENT_PATH)? { + return Ok(false); + } + Ok(find_bls_for_digest(&mnt.fd, digest)?.is_some()) +} + +fn xbootldr_is_usable(dev: &bootc_blockdev::Device, unusable_log_level: tracing::Level) -> bool { + let fstype = dev.fstype.as_deref(); + if fstype == Some(XBOOTLDR_FSTYPE) { + return true; + } + let msg = format!( + "Not using XBOOTLDR partition {}: its filesystem is {}, but systemd-boot can only read {XBOOTLDR_FSTYPE}; using the ESP instead", + dev.path(), + fstype.unwrap_or("unknown"), + ); + if unusable_log_level == tracing::Level::WARN { + tracing::warn!("{msg}"); + } else { + tracing::debug!("{msg}"); + } + false +} + /// Filename release field for primary (new/upgraded) entry. /// Grub parses this as the "release" field and sorts descending, so "1" > "0". pub(crate) const FILENAME_PRIORITY_PRIMARY: &str = "1"; @@ -744,7 +875,7 @@ pub(crate) fn setup_composefs_bls_boot( ) -> Result { let id_hex = id.to_hex(); - let (root_path, esp_device, mut cmdline_refs, bootloader) = match setup_type { + let (root_path, boot_parts, 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(); @@ -777,12 +908,13 @@ pub(crate) fn setup_composefs_bls_boot( } } - // Locate ESP partition device by walking up to the root disk(s) - let esp_part = root_setup.device_info.find_first_colocated_esp()?; + // Locate the ESP (and XBOOTLDR) by walking up to the root disk(s) + let boot_parts = + BootPartitions::find(&root_setup.device_info, &postfetch.detected_bootloader)?; ( root_setup.physical_root_path.clone(), - esp_part.path(), + boot_parts, cmdline_options, postfetch.detected_bootloader.clone(), ) @@ -814,13 +946,11 @@ pub(crate) fn setup_composefs_bls_boot( ), )?; - // 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()?; + let boot_parts = storage.require_boot_partitions()?.clone(); ( Utf8PathBuf::from("/sysroot"), - esp_dev.path(), + boot_parts, cmdline, bootloader, ) @@ -863,18 +993,19 @@ pub(crate) fn setup_composefs_bls_boot( } BootloaderKind::BLSCompatible => { - let efi_mount = mount_esp_writable(&esp_device).context("Mounting ESP")?; + let boot_mount = + mount_esp_writable(boot_parts.boot()).context("Mounting boot partition")?; - let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?); - let efi_linux_dir = mounted_efi.join(EFI_LINUX); + let mounted_boot = Utf8PathBuf::from(boot_mount.dir.path().as_str()?); + let efi_linux_dir = mounted_boot.join(EFI_LINUX); ( BLSEntryPath { entries_path: efi_linux_dir, - config_path: mounted_efi.clone(), + config_path: mounted_boot.clone(), abs_entries_path: Utf8PathBuf::from("/").join(EFI_LINUX), }, - Some(efi_mount), + Some(boot_mount), ) } }; @@ -1675,7 +1806,7 @@ fn write_grub_uki_menuentry( #[context("Writing systemd UKI config")] fn write_systemd_uki_config( - esp_dir: &Dir, + boot_dir: &Dir, setup_type: &BootSetupType, boot_label: String, version: Option, @@ -1700,22 +1831,22 @@ fn write_systemd_uki_config( let (entries_dir, booted_bls) = match setup_type { BootSetupType::Setup(..) => { - esp_dir + boot_dir .create_dir_all(TYPE1_ENT_PATH) .with_context(|| format!("Creating {TYPE1_ENT_PATH}"))?; - (esp_dir.open_dir(TYPE1_ENT_PATH)?, None) + (boot_dir.open_dir(TYPE1_ENT_PATH)?, None) } BootSetupType::Upgrade((_, booted_cfs, ..)) => { - esp_dir + boot_dir .create_dir_all(TYPE1_ENT_PATH_STAGED) .with_context(|| format!("Creating {TYPE1_ENT_PATH_STAGED}"))?; - let mut booted_bls = get_booted_bls(&esp_dir, booted_cfs)?; + let mut booted_bls = get_booted_bls(&boot_dir, booted_cfs)?; booted_bls.sort_key = Some(secondary_sort_key(os_id)); - (esp_dir.open_dir(TYPE1_ENT_PATH_STAGED)?, Some(booted_bls)) + (boot_dir.open_dir(TYPE1_ENT_PATH_STAGED)?, Some(booted_bls)) } }; @@ -1733,18 +1864,23 @@ fn write_systemd_uki_config( )?; } - // Write the timeout for bootloader menu if not exists - if !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}"))?; - } - - let esp_dir = esp_dir + let boot_dir = boot_dir .reopen_as_ownedfd() .context("Reopening as owned fd")?; - rustix::fs::fsync(esp_dir).context("fsync")?; + rustix::fs::fsync(boot_dir).context("fsync")?; + + Ok(()) +} +/// Write the timeout for the systemd-boot menu, unless `loader.conf` exists. +#[context("Writing {SYSTEMD_LOADER_CONF_PATH}")] +fn write_systemd_loader_conf(esp_dir: &Dir) -> Result<()> { + if esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) { + return Ok(()); + } + esp_dir.create_dir_all("loader")?; + esp_dir.atomic_write(SYSTEMD_LOADER_CONF_PATH, SYSTEMD_TIMEOUT)?; + rustix::fs::fsync(esp_dir.reopen_as_ownedfd()?).context("fsync")?; Ok(()) } @@ -1756,17 +1892,18 @@ pub(crate) fn setup_composefs_uki_boot( boot_ids: &ExpectedBootImageIds, entries: Vec>, ) -> Result<(String, Sha512HashValue)> { - let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type + let (root_path, boot_parts, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { state.require_no_kargs_for_uki()?; - // Locate ESP partition device by walking up to the root disk(s) - let esp_part = root_setup.device_info.find_first_colocated_esp()?; + // Locate the ESP (and XBOOTLDR) by walking up to the root disk(s) + let boot_parts = + BootPartitions::find(&root_setup.device_info, &postfetch.detected_bootloader)?; ( root_setup.physical_root_path.clone(), - esp_part.path(), + boot_parts, postfetch.detected_bootloader.clone(), allow_missing_fsverity, state.composefs_options.uki_addon.as_ref(), @@ -1777,13 +1914,11 @@ 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 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()?; + let boot_parts = storage.require_boot_partitions()?.clone(); ( sysroot, - esp_dev.path(), + boot_parts, bootloader, booted_cfs.cmdline.allow_missing_fsverity, // TODO: We never (re)install UKI addons on upgrade, only on initial @@ -1795,7 +1930,7 @@ pub(crate) fn setup_composefs_uki_boot( } }; - let esp_mount = mount_esp_writable(&esp_device).context("Mounting ESP")?; + let boot_mount = mount_esp_writable(boot_parts.boot()).context("Mounting boot partition")?; let mut uki_info: Option = None; @@ -1843,7 +1978,7 @@ pub(crate) fn setup_composefs_uki_boot( &id, boot_ids, missing_fsverity_allowed, - esp_mount.dir.path(), + boot_mount.dir.path(), )?; if let Some(label) = ret { @@ -1865,19 +2000,38 @@ pub(crate) fn setup_composefs_uki_boot( } = uki_info; match bootloader.kind()? { - BootloaderKind::GRUBClassic => { - write_grub_uki_menuentry(root_path, &setup_type, boot_label, &deploy_id, &esp_device)? - } - - BootloaderKind::BLSCompatible => write_systemd_uki_config( - &esp_mount.fd, + BootloaderKind::GRUBClassic => write_grub_uki_menuentry( + root_path, &setup_type, boot_label, - version, - os_id, &deploy_id, - &bootloader, + &boot_parts.esp, )?, + + BootloaderKind::BLSCompatible => { + write_systemd_uki_config( + &boot_mount.fd, + &setup_type, + boot_label, + version, + os_id, + &deploy_id, + &bootloader, + )?; + // systemd-boot only reads loader.conf from the ESP. It's only + // written at install time, so that upgrades never touch the ESP + // when XBOOTLDR is used. + if matches!(setup_type, BootSetupType::Setup(..)) { + match boot_parts.xbootldr { + Some(_) => { + let esp_mount = + mount_esp_writable(&boot_parts.esp).context("Mounting ESP")?; + write_systemd_loader_conf(&esp_mount.fd)?; + } + None => write_systemd_loader_conf(&boot_mount.fd)?, + } + } + } }; Ok((boot_digest, deploy_id)) @@ -1945,10 +2099,10 @@ 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. + // Mount the ESP at /boot even if there's an XBOOTLDR partition: this + // root is only used to install the boot loader itself, which always + // goes on the ESP. bootc writes the entries to XBOOTLDR itself (see + // `BootPartitions`). let esp_subdir = "boot"; // Mount a tmpfs over /tmp so that tools invoked with --root have a @@ -2275,6 +2429,47 @@ mod tests { use super::*; use composefs::erofs::format::FormatVersion; + #[test] + fn test_boot_partitions() -> Result<()> { + let dev = |name: &str, fstype: Option<&str>| -> bootc_blockdev::Device { + serde_json::from_value(serde_json::json!({ + "name": name, + "path": format!("/dev/{name}"), + "size": 0, + "fstype": fstype, + })) + .unwrap() + }; + let esp = dev("vda2", Some("vfat")); + let cases = [ + // systemd-boot uses a FAT XBOOTLDR partition... + (Bootloader::Systemd, Some("vfat"), Some("/dev/vda3")), + // ...but can't read any other filesystem + (Bootloader::Systemd, Some("ext4"), None), + (Bootloader::Systemd, None, None), + // GRUB keeps its entries on /boot + (Bootloader::Grub, Some("vfat"), None), + ]; + for (bootloader, fstype, expected) in cases { + let parts = BootPartitions::new( + &esp, + Some(dev("vda3", fstype)), + &bootloader, + tracing::Level::DEBUG, + )?; + assert_eq!(parts.esp, "/dev/vda2"); + assert_eq!( + parts.xbootldr.as_deref(), + expected, + "{bootloader} {fstype:?}" + ); + assert_eq!(parts.boot(), expected.unwrap_or("/dev/vda2")); + } + let parts = BootPartitions::new(&esp, None, &Bootloader::Systemd, tracing::Level::DEBUG)?; + assert_eq!(parts.boot(), "/dev/vda2"); + Ok(()) + } + #[test] fn test_replace_composefs_karg() { let mut cmdline = diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 97295f3896..f53c74426b 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -62,7 +62,7 @@ fn delete_type1_conf_file( continue; } - // Boot dir in case of EFI will be the ESP + // Boot dir in case of EFI will be the ESP, or XBOOTLDR tracing::debug!("Deleting EFI .conf file: {}", file_name); entry.remove_file().context("Removing .conf file")?; diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 5e0e771588..b054e18f76 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -77,10 +77,10 @@ fn image_refs_match( fn collect_boot_binaries(storage: &Storage) -> Result> { let mut boot_binaries = Vec::new(); let boot_dir = storage.bls_boot_binaries_dir()?; - let esp = storage.require_esp()?; + let uki_partition = storage.require_uki_partition()?; // Scan for UKI binaries in EFI/Linux/bootc - collect_uki_binaries(&esp.fd, &mut boot_binaries)?; + collect_uki_binaries(uki_partition, &mut boot_binaries)?; // Scan for Type1 boot binaries (kernels + initrds) in `boot_dir` // depending upon whether systemd-boot is being used, or grub @@ -155,12 +155,10 @@ fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) - /// Deletes the UKI `uki_id` and any addons specific to it #[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")] fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { - let esp_mnt = storage.require_esp()?; - // NOTE: We don't delete global addons here (see `GLOBAL_UKI_ADDONS_DIR`) // Which is fine as global addons don't belong to any single deployment, but it also // means they're never cleaned up at all: see the TODO on `GLOBAL_UKI_ADDONS_DIR`. - let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?; + let uki_dir = storage.require_uki_partition()?.open_dir(BOOTC_UKI_DIR)?; for entry in uki_dir.entries_utf8()? { let entry = entry?; diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 9f15d31fca..2adf5d8f8b 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -35,7 +35,7 @@ use crate::{ composefs_consts::{ COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, - SHARED_VAR_PATH, STATE_DIR_RELATIVE, + SHARED_VAR_PATH, STATE_DIR_RELATIVE, TYPE1_ENT_PATH, }, parsers::bls_config::BLSConfig, spec::ImageReference, @@ -65,6 +65,16 @@ pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result Result { + find_bls_for_digest(boot_dir, &booted_cfs.cmdline.digest)?.ok_or_else(|| { + anyhow::anyhow!( + "No boot entry for the booted deployment in {TYPE1_ENT_PATH} (on the XBOOTLDR partition if bootc uses one, otherwise on the ESP)" + ) + }) +} + +/// Find the Type #1 entry in `boot_dir` for the deployment with the composefs +/// digest `digest`. +pub(crate) fn find_bls_for_digest(boot_dir: &Dir, digest: &str) -> Result> { let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?; for entry in sorted_entries { @@ -73,8 +83,8 @@ pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Re let path = match key { EFIKey::Efi(path) | EFIKey::Uki(path) => path, }; - if path.as_str().contains(&*booted_cfs.cmdline.digest) { - return Ok(entry); + if path.as_str().contains(digest) { + return Ok(Some(entry)); } } @@ -86,8 +96,8 @@ pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Re let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(opts))? .ok_or_else(|| anyhow::anyhow!("composefs param not found in cmdline"))?; - if cfs_cmdline.digest == booted_cfs.cmdline.digest { - return Ok(entry); + if &*cfs_cmdline.digest == digest { + return Ok(Some(entry)); } } @@ -95,7 +105,7 @@ pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Re }; } - Err(anyhow::anyhow!("Booted BLS not found")) + Ok(None) } /// Mounts an EROFS image and copies the pristine /etc and /var to the deployment's /etc and /var. diff --git a/crates/lib/src/bootc_composefs/utils.rs b/crates/lib/src/bootc_composefs/utils.rs index f7aab3eea2..6ebbdaaca4 100644 --- a/crates/lib/src/bootc_composefs/utils.rs +++ b/crates/lib/src/bootc_composefs/utils.rs @@ -11,7 +11,7 @@ use fn_error_context::context; use linux_kernel_cmdline::utf8::Cmdline; fn get_uki(storage: &Storage, deployment_verity: &str) -> Result { - let uki_dir = storage.require_esp()?.fd.open_dir(BOOTC_UKI_DIR)?; + let uki_dir = storage.require_uki_partition()?.open_dir(BOOTC_UKI_DIR)?; let req_fname = get_uki_name(deployment_verity); diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 8b44c27b18..666adc55ef 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 entries itself, to XBOOTLDR if + // there is one (see `BootPartitions`). ]; if configopts.generic_image { diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index e25ac67c1c..9535f09ff6 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -117,7 +117,9 @@ use composefs::repository::{RepositoryConfig, RepositoryOpenError}; use composefs_ctl::composefs; use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; -use crate::bootc_composefs::boot::{EFI_LINUX, mount_esp_readonly, mount_esp_writable}; +use crate::bootc_composefs::boot::{ + BootPartitions, EFI_LINUX, mount_esp_readonly, mount_esp_writable, +}; use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader}; use crate::lsm; use crate::podstorage::CStorage; @@ -474,25 +476,38 @@ impl BootedStorage { } let composefs = Arc::new(composefs); - // Locate ESP by walking up to the root disk(s). Both mount - // variants transparently reuse an already-mounted ESP when - // present (e.g. auto-mounted at /boot ro via - // `systemd.mount-extra` in the deployment cmdline). + // Locate the ESP (and XBOOTLDR) by walking up to the root + // disk(s). Both mount variants transparently reuse an + // already-mounted partition when present (e.g. auto-mounted + // at /boot ro via `systemd.mount-extra` in the deployment + // cmdline). let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?; - let esp_dev = root_dev.find_first_colocated_esp()?; - let esp_path = esp_dev.path(); - let esp_mount = match esp_access { - EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?, - EspAccess::ReadWrite => mount_esp_writable(&esp_path)?, + let bootloader = get_bootloader()?; + let boot_parts = + BootPartitions::find_booted(&root_dev, &bootloader, &cmdline.digest)?; + let mount = |dev: &str| match esp_access { + EspAccess::ReadOnly => mount_esp_readonly(dev), + EspAccess::ReadWrite => mount_esp_writable(dev), + }; + // With XBOOTLDR, nothing after the install touches the ESP, + // so leave it alone: it may be an automount (on /efi) that + // expires, e.g. while finalizing a staged deployment at + // shutdown. + let (esp_mount, xbootldr_mount) = match boot_parts.xbootldr.as_deref() { + Some(xbootldr) => (None, Some(mount(xbootldr)?)), + None => (Some(mount(&boot_parts.esp)?), None), }; - let boot_dir = match get_bootloader()?.kind()? { + let boot_dir = match bootloader.kind()? { // We can have a separate /boot and not /sysroot/boot BootloaderKind::GRUBClassic => get_boot_dir_for_grub(&physical_root)?, - // NOTE: Handle XBOOTLDR partitions here if and when we use it - BootloaderKind::BLSCompatible => { - esp_mount.fd.try_clone().context("Cloning fd")? - } + BootloaderKind::BLSCompatible => xbootldr_mount + .as_ref() + .or(esp_mount.as_ref()) + .ok_or_else(|| anyhow::anyhow!("BUG: no boot partition mounted"))? + .fd + .try_clone() + .context("Cloning fd")?, }; let storage = Storage { @@ -501,7 +516,9 @@ impl BootedStorage { is_ro, run, boot_dir: Some(boot_dir), - esp: Some(esp_mount), + esp: esp_mount, + xbootldr: xbootldr_mount, + boot_partitions: Some(boot_parts), ostree: Default::default(), composefs: OnceCell::from(composefs.clone()), imgstore: Default::default(), @@ -547,6 +564,8 @@ impl BootedStorage { run, boot_dir: None, esp: None, + xbootldr: None, + boot_partitions: None, ostree: OnceCell::from(sysroot), composefs: Default::default(), imgstore: Default::default(), @@ -600,12 +619,21 @@ pub(crate) struct Storage { /// The 'boot' directory, useful and `Some` only for composefs systems /// For grub booted systems, this points to `/sysroot/boot` - /// For systemd booted systems, this points to the ESP + /// For systemd booted systems, this points to the XBOOTLDR partition if + /// bootc uses one, and to the ESP otherwise pub boot_dir: Option, - /// The ESP mounted at a tmp location + /// The ESP mounted at a tmp location; `None` on composefs systems using + /// XBOOTLDR pub esp: Option, + /// The XBOOTLDR partition mounted at a tmp location, if bootc keeps + /// the systemd-boot entries and UKIs there + pub(crate) xbootldr: Option, + + /// The composefs system's ESP and XBOOTLDR devices, as found at startup + pub(crate) boot_partitions: Option, + /// Our runtime state run: Dir, @@ -668,6 +696,8 @@ impl Storage { run, boot_dir: None, esp: None, + xbootldr: None, + boot_partitions: None, ostree: ostree_cell, composefs: Default::default(), imgstore: Default::default(), @@ -698,12 +728,29 @@ impl Storage { .ok_or_else(|| anyhow::anyhow!("ESP not found")) } + /// Returns the ESP and XBOOTLDR devices of a composefs system + pub(crate) fn require_boot_partitions(&self) -> Result<&BootPartitions> { + self.boot_partitions + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Boot partitions not found")) + } + + /// Returns the partition holding the UKIs: the XBOOTLDR partition if bootc + /// uses one, and the ESP otherwise + pub(crate) fn require_uki_partition(&self) -> Result<&Dir> { + let mount = match &self.xbootldr { + Some(xbootldr) => xbootldr, + None => self.require_esp()?, + }; + Ok(&mount.fd) + } + /// Returns the Directory where the Type1 boot binaries are stored - /// `/sysroot/boot` for Grub, and ESP/EFI/Linux for systemd-boot + /// `/sysroot/boot` for Grub, and EFI/Linux on the ESP or XBOOTLDR for systemd-boot pub(crate) fn bls_boot_binaries_dir(&self) -> Result { let boot_dir = self.require_boot_dir()?; - // boot dir in case of systemd-boot points to the ESP, but we store + // boot dir in case of systemd-boot points to the ESP or XBOOTLDR, but we store // the actual binaries inside ESP/EFI/Linux let boot_dir = match get_bootloader()?.kind()? { BootloaderKind::GRUBClassic => boot_dir.try_clone()?, diff --git a/docs/src/bootc-bootloaders.7.md b/docs/src/bootc-bootloaders.7.md index 6cd22cb17d..973dcfd86e 100644 --- a/docs/src/bootc-bootloaders.7.md +++ b/docs/src/bootc-bootloaders.7.md @@ -18,6 +18,14 @@ NOTE: systemd-boot is only supported for Composefs Backend and not for Ostree If bootupd is not present in the input container image, then systemd-boot will be used by default (except on s390x). +systemd-boot itself is installed on the ESP. If the disk holding the ESP also has an +[XBOOTLDR](https://uapi-group.org/specifications/specs/boot_loader_specification/) +partition formatted as FAT (the only filesystem systemd-boot can read in practice), bootc +puts the boot entries, kernels and UKIs there instead of on the ESP, as the Boot Loader +Specification describes. An existing system keeps its entries where they were installed. +Only the first ESP is used; on systems with several ESPs (for example one per disk of a +RAID1 root), the others are left alone. + ## s390x bootc uses `zipl`. diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 06dadb2cf3..1d92584f1d 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -331,4 +331,13 @@ execute: enabled: true extra-try_bind_storage: true extra-skip_if_ostree: true + +/plan-55-composefs-xbootldr: + summary: Test composefs with systemd-boot entries on an XBOOTLDR partition + discover: + how: fmf + test: + - /tmt/tests/tests/test-55-composefs-xbootldr + extra-skip_if_ostree: true + extra-fixme_skip_if_uki: 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..b7ba8be883 --- /dev/null +++ b/tmt/tests/booted/test-composefs-xbootldr.nu @@ -0,0 +1,277 @@ +# number: 55 +# tmt: +# summary: Test composefs with systemd-boot entries on an XBOOTLDR partition +# duration: 90m +# require: +# - dosfstools +# - e2fsprogs +# extra: +# skip_if_ostree: true +# fixme_skip_if_uki: true +# +# With an XBOOTLDR partition next to the ESP, bootc puts the Type #1 entries +# and kernels on XBOOTLDR, per the Boot Loader Specification, and only +# systemd-boot itself on the ESP. +# +# 1. `bootc install to-filesystem` onto a loop device with an ESP, an +# XBOOTLDR partition and a root filesystem puts the entries on XBOOTLDR. +# Booting that disk isn't possible here, so to test a booted system, the +# VM's own ESP is then split into a smaller ESP and an XBOOTLDR partition. +# While XBOOTLDR is still unused, bootc keeps using the ESP. Then +# bootc's entries and kernels are moved to XBOOTLDR, as if installed there. +# 2. After a reboot, bootc uses XBOOTLDR: switch to a derived image with a +# different initramfs, so it gets its own kernel directory. +# 3. That boots, with its entries on XBOOTLDR too: roll back. +# 4. The rollback worked: switch to another derived image that shares the +# original kernel. +# 5. That boots, and GC removed the first derived image's kernel from +# XBOOTLDR. Nothing of bootc's ever went 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 LINUX_FS_TYPE = "0FC63DAF-8483-4772-8E79-3D69D8477DE4" +# The size the VM's ESP is shrunk to; XBOOTLDR gets the rest of it +const NEW_ESP_SIZE_MIB = 256 +const STATE_FILE = "/var/xbootldr-test.json" +const DERIVED_IMAGE = "localhost/bootc-xbootldr-derived" +const DERIVED_IMAGE_2 = "localhost/bootc-xbootldr-derived2" +const MARKER = "/usr/share/bootc-xbootldr-marker" +const MARKER_2 = "/usr/share/bootc-xbootldr-marker2" +const SECTOR_SIZE = 512 + +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 +} + +# 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 +} + +# The bootc-owned Type #1 entries and kernel directories on a partition. +def bootc_boot_files [dir: string] { + glob $"($dir)/loader/entries*/bootc*.conf" | append (glob $"($dir)/EFI/Linux/bootc_composefs-*") +} + +# Check that bootc's entries and kernels are on XBOOTLDR, and that the ESP +# only has systemd-boot. +def assert_boot_layout [xbootldr_dir: string, esp_dir: string] { + let files = (bootc_boot_files $xbootldr_dir) + print $"bootc files on XBOOTLDR: ($files)" + assert ($files | any { |f| $f | str ends-with ".conf" }) "No bootc entries on XBOOTLDR" + assert ($files | any { |f| $f | str contains "bootc_composefs-" }) "No kernels on XBOOTLDR" + assert equal (bootc_boot_files $esp_dir) [] "bootc files on the ESP" + assert (glob $"($esp_dir)/EFI/systemd/systemd-boot*.efi" | is-not-empty) "No systemd-boot on the ESP" +} + +def kernel_dirs [dir: string] { + glob $"($dir)/EFI/Linux/bootc_composefs-*" | each { path basename } | sort +} + +def assert_booted_layout [] { + let state = (open $STATE_FILE) + assert_boot_layout (find_or_mount $state.xbootldr "xbootldr") (find_or_mount $state.esp "esp") +} + +# Install to a loop device with an ESP, an XBOOTLDR partition and a root. +def test_install [] { + let disk_img = "/var/tmp/xbootldr-disk.img" + let target = "/var/mnt/target" + truncate -s 12G $disk_img + let loop = (losetup -f --show -P $disk_img | str trim) + $"label: gpt\nsize=512M, type=($ESP_TYPE)\nsize=1G, type=($XBOOTLDR_TYPE)\ntype=($LINUX_FS_TYPE)\n" | sfdisk $loop + partx -u $loop + udevadm settle + mkfs.vfat -F 32 -n ESP $"($loop)p1" + mkfs.vfat -F 32 -n XBOOTLDR $"($loop)p2" + mkfs.ext4 -q -O verity $"($loop)p3" + # bootc gets the filesystem types from udev + udevadm settle + + mkdir $target + mount $"($loop)p3" $target + mkdir $"($target)/boot" + mount $"($loop)p2" $"($target)/boot" + + # Logically bound images would need to be pulled at install time + bootc image copy-to-storage + "FROM localhost/bootc\nRUN rm -rf /usr/lib/bootc/bound-images.d/*\n" | podman build -t localhost/bootc-install -f - . + (podman run --rm --privileged --pid=host + -v /dev:/dev -v $"($target):/target" + --security-opt label=type:unconfined_t + localhost/bootc-install + bootc install to-filesystem --disable-selinux --composefs-backend --bootloader systemd /target) + + mkdir /var/mnt/target-esp + mount $"($loop)p1" /var/mnt/target-esp + assert_boot_layout $"($target)/boot" /var/mnt/target-esp + + umount /var/mnt/target-esp + umount -R $target + losetup -d $loop + rm $disk_img + podman rmi localhost/bootc-install +} + +# Split the booted ESP into a smaller ESP and an XBOOTLDR partition, and +# move bootc's entries and kernels to XBOOTLDR. +def split_esp [] { + 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 = ($esp.node | str replace -r '.*[^0-9]' '' | into int) + let esp_uuid = (blkid -s UUID -o value $esp.node | str trim) + print $"Disk ($disk), ESP ($esp.node) \(($esp.size) sectors\)" + + let backup = "/var/tmp/esp-backup" + let esp_dir = (find_or_mount $esp.node "esp") + /usr/bin/cp -a -T $esp_dir $backup + # Unmount the ESP, including any automounts (which only exist for the + # mount points the image has) + for unit in [boot.automount efi.automount boot.mount efi.mount] { + if (systemctl is-active --quiet $unit | complete).exit_code == 0 { + systemctl stop $unit + } + } + for t in (findmnt -rn -o TARGET -S $esp.node | lines) { + umount $t + } + + let new_esp_sectors = $NEW_ESP_SIZE_MIB * 1024 * 1024 / $SECTOR_SIZE + $"start=($esp.start), size=($new_esp_sectors), type=($ESP_TYPE)\n" | sfdisk --no-reread -N $esp_nr $disk + let old_nodes = (partitions $disk | get node) + $"start=($esp.start + $new_esp_sectors), size=($esp.size - $new_esp_sectors), type=($XBOOTLDR_TYPE)\n" | sfdisk --no-reread --append $disk + let xbootldr = (partitions $disk | where { |p| $p.node not-in $old_nodes } | first).node + let xbootldr_nr = ($xbootldr | str replace -r '.*[^0-9]' '') + partx -u --nr $esp_nr $disk + partx -a --nr $xbootldr_nr $disk + udevadm settle + print $"New ESP ($esp.node), XBOOTLDR ($xbootldr)" + + # Keep the ESP's filesystem UUID, in case something refers to it + mkfs.vfat -F 32 -n ESP -i ($esp_uuid | str replace "-" "") $esp.node + mkfs.vfat -F 32 -n XBOOTLDR $xbootldr + udevadm settle + + let esp_dir = (find_or_mount $esp.node "esp") + let xbootldr_dir = (find_or_mount $xbootldr "xbootldr") + /usr/bin/cp -r -T $backup $esp_dir + mkdir $"($xbootldr_dir)/loader/entries" $"($xbootldr_dir)/EFI/Linux" + + # With no entry for the booted deployment on XBOOTLDR, bootc keeps using + # the ESP, as on systems that were installed before it supported XBOOTLDR + let out = (with-env { RUST_LOG: "bootc_lib=debug" } { bootc status | complete }) + assert equal $out.exit_code 0 $out.stderr + assert ($out.stderr | str contains $"No entry for the booted deployment on ($xbootldr), using the ESP") $out.stderr + + for f in (glob $"($esp_dir)/loader/entries/bootc*.conf") { + /usr/bin/mv $f $"($xbootldr_dir)/loader/entries/" + } + for d in (glob $"($esp_dir)/EFI/Linux/bootc_composefs-*") { + /usr/bin/mv $d $"($xbootldr_dir)/EFI/Linux/" + } + rm -rf $backup + assert_boot_layout $xbootldr_dir $esp_dir + + { esp: $esp.node, xbootldr: $xbootldr } | to json | save -f $STATE_FILE + sync +} + +def first_boot [] { + tap begin "composefs with XBOOTLDR" + + let st = bootc status --json | from json + let bootloader = ($st.status.booted.composefs.bootloader | str downcase) + if $bootloader != "systemd" { + print $"XBOOTLDR is only used with systemd-boot, not ($bootloader); skipping" + tap ok + return + } + + test_install + split_esp + tmt-reboot +} + +def second_boot [] { + assert_booted_layout + bootc status + + let state = (open $STATE_FILE) + let xbootldr_dir = (find_or_mount $state.xbootldr "xbootldr") + let original_kernels = (kernel_dirs $xbootldr_dir) + assert equal ($original_kernels | length) 1 + $state | insert kernel $original_kernels.0 | to json | save -f $STATE_FILE + + # Zero padding at the end of the initramfs is harmless, but gives the + # derived image a kernel directory of its own + bootc image copy-to-storage + let containerfile = $"FROM localhost/bootc +RUN echo xbootldr > ($MARKER) && for f in /usr/lib/modules/*/initramfs.img; do head -c 512 /dev/zero >> $f; done +" + $containerfile | podman build -t $DERIVED_IMAGE -f - . + bootc switch --transport containers-storage $DERIVED_IMAGE + + # The staged entries and the new kernel go to XBOOTLDR as well + assert (glob $"($xbootldr_dir)/loader/entries.staged/*.conf" | is-not-empty) "No staged entries on XBOOTLDR" + assert equal (kernel_dirs $xbootldr_dir | length) 2 + + tmt-reboot +} + +def third_boot [] { + assert ($MARKER | path exists) "Not booted into the derived image" + let booted = (bootc status --json | from json).status.booted.image.image.image + assert equal $booted $DERIVED_IMAGE + assert_booted_layout + + bootc rollback + assert (bootc status --json | from json).status.rollbackQueued + tmt-reboot +} + +def fourth_boot [] { + assert (not ($MARKER | path exists)) "Still booted into the derived image after rollback" + assert_booted_layout + + $"FROM localhost/bootc\nRUN echo xbootldr > ($MARKER_2)\n" | podman build -t $DERIVED_IMAGE_2 -f - . + bootc switch --transport containers-storage $DERIVED_IMAGE_2 + tmt-reboot +} + +def fifth_boot [] { + assert ($MARKER_2 | path exists) "Not booted into the second derived image" + assert_booted_layout + + # Only the original kernel is still referenced; GC removed the other one + let state = (open $STATE_FILE) + let xbootldr_dir = (find_or_mount $state.xbootldr "xbootldr") + assert equal (kernel_dirs $xbootldr_dir) [$state.kernel] + 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 f27e9addd5..c7db6cda10 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -206,3 +206,11 @@ check: - when: composefs_bridge == true enabled: true test: nu booted/test-49-composefs-1-16-bridge.nu + +/test-55-composefs-xbootldr: + summary: Test composefs with systemd-boot entries on an XBOOTLDR partition + duration: 90m + require: + - dosfstools + - e2fsprogs + test: nu booted/test-composefs-xbootldr.nu