From b224d7ebc1fb82b3179e63f9504fc98930cb6e7b Mon Sep 17 00:00:00 2001 From: HuijingHei Date: Wed, 8 Jan 2025 20:48:37 +0800 Subject: [PATCH] Support updating multiple EFIs in mirrored setups(RAID1) The EFI System Partition is not mounted after booted, on systems configured with boot device mirroring, there are independent EFI partitions on each constituent disk, need to mount each disk and updates. Xref to https://github.com/coreos/bootupd/issues/132 --- src/bios.rs | 61 +------ src/blockdev.rs | 206 +++++++++++++++++++++++ src/efi.rs | 165 +++++++++++------- src/main.rs | 1 + tests/fixtures/example-lsblk-output.json | 7 + 5 files changed, 326 insertions(+), 114 deletions(-) create mode 100644 src/blockdev.rs diff --git a/src/bios.rs b/src/bios.rs index f8c644e4..d61e3f63 100644 --- a/src/bios.rs +++ b/src/bios.rs @@ -2,29 +2,17 @@ use std::io::prelude::*; use std::path::Path; use std::process::Command; +use crate::blockdev; use crate::component::*; use crate::model::*; use crate::packagesystem; use anyhow::{bail, Result}; use crate::util; -use serde::{Deserialize, Serialize}; // grub2-install file path pub(crate) const GRUB_BIN: &str = "usr/sbin/grub2-install"; -#[derive(Serialize, Deserialize, Debug)] -struct BlockDevice { - path: String, - pttype: Option, - parttypename: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -struct Devices { - blockdevices: Vec, -} - #[derive(Default)] pub(crate) struct Bios {} @@ -116,36 +104,10 @@ impl Bios { // check bios_boot partition on gpt type disk fn get_bios_boot_partition(&self) -> Result> { - let target = self.get_device()?; + let device = self.get_device()?; + let target = device.trim(); // lsblk to list children with bios_boot - let output = Command::new("lsblk") - .args([ - "--json", - "--output", - "PATH,PTTYPE,PARTTYPENAME", - target.trim(), - ]) - .output()?; - if !output.status.success() { - std::io::stderr().write_all(&output.stderr)?; - bail!("Failed to run lsblk"); - } - - let output = String::from_utf8(output.stdout)?; - // Parse the JSON string into the `Devices` struct - let Ok(devices) = serde_json::from_str::(&output) else { - bail!("Could not deserialize JSON output from lsblk"); - }; - - // Find the device with the parttypename "BIOS boot" - for device in devices.blockdevices { - if let Some(parttypename) = &device.parttypename { - if parttypename == "BIOS boot" && device.pttype.as_deref() == Some("gpt") { - return Ok(Some(device.path)); - } - } - } - Ok(None) + blockdev::get_bios_boot_partition(&target) } } @@ -235,18 +197,3 @@ impl Component for Bios { Ok(None) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_deserialize_lsblk_output() { - let data = include_str!("../tests/fixtures/example-lsblk-output.json"); - let devices: Devices = serde_json::from_str(&data).expect("JSON was not well-formatted"); - assert_eq!(devices.blockdevices.len(), 7); - assert_eq!(devices.blockdevices[0].path, "/dev/sr0"); - assert!(devices.blockdevices[0].pttype.is_none()); - assert!(devices.blockdevices[0].parttypename.is_none()); - } -} diff --git a/src/blockdev.rs b/src/blockdev.rs new file mode 100644 index 00000000..449c56c8 --- /dev/null +++ b/src/blockdev.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; + +use crate::util; +use anyhow::{bail, Context, Result}; +use fn_error_context::context; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +struct BlockDevices { + blockdevices: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Device { + path: String, + pttype: Option, + parttype: Option, + parttypename: Option, +} + +impl Device { + pub(crate) fn is_esp_part(&self) -> bool { + const ESP_TYPE_GUID: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; + if let Some(parttype) = &self.parttype { + if parttype.to_lowercase() == ESP_TYPE_GUID { + return true; + } + } + false + } + + pub(crate) fn is_bios_boot_part(&self) -> bool { + const BIOS_BOOT_TYPE_GUID: &str = "21686148-6449-6e6f-744e-656564454649"; + if let Some(parttype) = &self.parttype { + if parttype.to_lowercase() == BIOS_BOOT_TYPE_GUID + && self.pttype.as_deref() == Some("gpt") + { + return true; + } + } + false + } +} + +/// Parse key-value pairs from lsblk --pairs. +/// Newer versions of lsblk support JSON but the one in CentOS 7 doesn't. +fn split_lsblk_line(line: &str) -> HashMap { + static REGEX: OnceLock = OnceLock::new(); + let regex = REGEX.get_or_init(|| Regex::new(r#"([A-Z-_]+)="([^"]+)""#).unwrap()); + let mut fields: HashMap = HashMap::new(); + for cap in regex.captures_iter(line) { + fields.insert(cap[1].to_string(), cap[2].to_string()); + } + fields +} + +/// This is a bit fuzzy, but... this function will return every block device in the parent +/// hierarchy of `device` capable of containing other partitions. So e.g. parent devices of type +/// "part" doesn't match, but "disk" and "mpath" does. +pub(crate) fn find_parent_devices(device: &str) -> Result> { + let mut cmd = Command::new("lsblk"); + // Older lsblk, e.g. in CentOS 7.6, doesn't support PATH, but --paths option + cmd.arg("--pairs") + .arg("--paths") + .arg("--inverse") + .arg("--output") + .arg("NAME,TYPE") + .arg(device); + let output = util::cmd_output(&mut cmd)?; + let mut parents = Vec::new(); + // skip first line, which is the device itself + for line in output.lines().skip(1) { + let dev = split_lsblk_line(line); + let name = dev + .get("NAME") + .with_context(|| format!("device in hierarchy of {device} missing NAME"))?; + let kind = dev + .get("TYPE") + .with_context(|| format!("device in hierarchy of {device} missing TYPE"))?; + if kind == "disk" { + parents.push(name.clone()); + } else if kind == "mpath" { + parents.push(name.clone()); + // we don't need to know what disks back the multipath + break; + } + } + if parents.is_empty() { + bail!("no parent devices found for {}", device); + } + Ok(parents) +} + +#[context("get backing devices from mountpoint boot")] +pub fn get_backing_devices>(target_root: P) -> Result> { + let target_root = target_root.as_ref(); + let bootdir = target_root.join("boot"); + if !bootdir.exists() { + bail!("{} does not exist", bootdir.display()); + } + let bootdir = openat::Dir::open(&bootdir)?; + let fsinfo = crate::filesystem::inspect_filesystem(&bootdir, ".")?; + // Find the real underlying backing device for the root. + let backing_devices = find_parent_devices(&fsinfo.source) + .with_context(|| format!("while looking for backing devices of {}", fsinfo.source))?; + log::debug!("Find backing devices: {backing_devices:?}"); + Ok(backing_devices) +} + +#[context("Listing parttype for device {device}")] +fn list_dev(device: &str) -> Result { + let mut cmd = Command::new("lsblk"); + cmd.args([ + "--json", + "--output", + "PATH,PTTYPE,PARTTYPE,PARTTYPENAME", + device, + ]); + let output = util::cmd_output(&mut cmd)?; + // Parse the JSON string into the `BlockDevices` struct + let Ok(devs) = serde_json::from_str::(&output) else { + bail!("Could not deserialize JSON output from lsblk"); + }; + Ok(devs) +} + +/// Find esp partition on the same device +pub fn get_esp_partition(device: &str) -> Result> { + let dev = list_dev(&device)?; + // Find the ESP part on the disk + for part in dev.blockdevices { + if part.is_esp_part() { + return Ok(Some(part.path)); + } + } + log::debug!("Not found any esp partition"); + Ok(None) +} + +/// Find all ESP partitions on the backing devices with mountpoint boot +pub fn find_colocated_esps>(target_root: P) -> Result> { + // first, get the parent device + let backing_devices = + get_backing_devices(&target_root).with_context(|| "while looking for colocated ESPs")?; + + // now, look for all ESPs on those devices + let mut esps = Vec::new(); + for parent_device in backing_devices { + if let Some(esp) = get_esp_partition(&parent_device)? { + esps.push(esp) + } + } + log::debug!("Find esp partitions: {esps:?}"); + Ok(esps) +} + +/// Find bios_boot partition on the same device +pub fn get_bios_boot_partition(device: &str) -> Result> { + let dev = list_dev(&device)?; + // Find the BIOS BOOT part on the disk + for part in dev.blockdevices { + if part.is_bios_boot_part() { + return Ok(Some(part.path)); + } + } + log::debug!("Not found any bios_boot partition"); + Ok(None) +} + +/// Find all bios_boot partitions on the backing devices with mountpoint boot +#[allow(dead_code)] +pub fn find_colocated_bios_boot>(target_root: P) -> Result> { + // first, get the parent device + let backing_devices = get_backing_devices(&target_root) + .with_context(|| "looking for colocated bios_boot parts")?; + + // now, look for all bios_boot parts on those devices + let mut bios_boots = Vec::new(); + for parent_device in backing_devices { + if let Some(bios) = get_bios_boot_partition(&parent_device)? { + bios_boots.push(bios) + } + } + log::debug!("Find bios_boot partitions: {bios_boots:?}"); + Ok(bios_boots) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_lsblk_output() { + let data = include_str!("../tests/fixtures/example-lsblk-output.json"); + let devices: BlockDevices = + serde_json::from_str(&data).expect("JSON was not well-formatted"); + assert_eq!(devices.blockdevices.len(), 7); + assert_eq!(devices.blockdevices[0].path, "/dev/sr0"); + assert!(devices.blockdevices[0].pttype.is_none()); + assert!(devices.blockdevices[0].parttypename.is_none()); + } +} diff --git a/src/efi.rs b/src/efi.rs index 29de6fb6..c315ba88 100644 --- a/src/efi.rs +++ b/src/efi.rs @@ -19,6 +19,7 @@ use rustix::fd::BorrowedFd; use walkdir::WalkDir; use widestring::U16CString; +use crate::blockdev; use crate::filetree; use crate::model::*; use crate::ostreeutil; @@ -57,28 +58,6 @@ pub(crate) struct Efi { } impl Efi { - fn esp_path(&self) -> Result { - self.ensure_mounted_esp(Path::new("/")) - .map(|v| v.join("EFI")) - } - - fn open_esp_optional(&self) -> Result> { - if !is_efi_booted()? && self.get_esp_device().is_none() { - log::debug!("Skip EFI"); - return Ok(None); - } - let sysroot = openat::Dir::open("/")?; - let esp = sysroot.sub_dir_optional(&self.esp_path()?)?; - Ok(esp) - } - - fn open_esp(&self) -> Result { - self.ensure_mounted_esp(Path::new("/"))?; - let sysroot = openat::Dir::open("/")?; - let esp = sysroot.sub_dir(&self.esp_path()?)?; - Ok(esp) - } - fn get_esp_device(&self) -> Option { let esp_devices = [COREOS_ESP_PART_LABEL, ANACONDA_ESP_PART_LABEL] .into_iter() @@ -93,11 +72,25 @@ impl Efi { return esp_device; } - pub(crate) fn ensure_mounted_esp(&self, root: &Path) -> Result { - let mut mountpoint = self.mountpoint.borrow_mut(); + fn get_all_esp_devices(&self) -> Option> { + let mut esp_devices = vec![]; + if let Some(esp_device) = self.get_esp_device() { + esp_devices.push(esp_device.to_string_lossy().into_owned()); + } else { + esp_devices = blockdev::find_colocated_esps("/").expect("get esp devices"); + }; + if !esp_devices.is_empty() { + return Some(esp_devices); + } + return None; + } + + fn check_existing_esp>(&self, root: P) -> Result> { + let mountpoint = self.mountpoint.borrow_mut(); if let Some(mountpoint) = mountpoint.as_deref() { - return Ok(mountpoint.to_owned()); + return Ok(Some(mountpoint.to_owned())); } + let root = root.as_ref(); for &mnt in ESP_MOUNTS { let mnt = root.join(mnt); if !mnt.exists() { @@ -110,12 +103,22 @@ impl Efi { } util::ensure_writable_mount(&mnt)?; log::debug!("Reusing existing {mnt:?}"); - return Ok(mnt); + return Ok(Some(mnt)); } + Ok(None) + } - let esp_device = self - .get_esp_device() - .ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?; + pub(crate) fn ensure_mounted_esp>( + &self, + root: P, + esp_device: &str, + ) -> Result { + let mut mountpoint = self.mountpoint.borrow_mut(); + if let Some(mountpoint) = mountpoint.as_deref() { + return Ok(mountpoint.to_owned()); + } + + let root = root.as_ref(); for &mnt in ESP_MOUNTS.iter() { let mnt = root.join(mnt); if !mnt.exists() { @@ -134,10 +137,9 @@ impl Efi { } Ok(mountpoint.as_deref().unwrap().to_owned()) } - fn unmount(&self) -> Result<()> { if let Some(mount) = self.mountpoint.borrow_mut().take() { - let status = Command::new("umount").arg(&mount).status()?; + let status = Command::new("umount").arg("-l").arg(&mount).status()?; if !status.success() { anyhow::bail!("Failed to unmount {mount:?}: {status:?}"); } @@ -245,8 +247,7 @@ impl Component for Efi { } fn query_adopt(&self) -> Result> { - let esp = self.open_esp_optional()?; - if esp.is_none() { + if self.get_all_esp_devices().is_none() { log::trace!("No ESP detected"); return Ok(None); }; @@ -269,16 +270,32 @@ impl Component for Efi { anyhow::bail!("Failed to find adoptable system") }; - let esp = self.open_esp()?; - validate_esp(&esp)?; let updated = sysroot .sub_dir(&component_updatedirname(self)) .context("opening update dir")?; let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; - // For adoption, we should only touch files that we know about. - let diff = updatef.relative_diff_to(&esp)?; - log::trace!("applying adoption diff: {}", &diff); - filetree::apply_diff(&updated, &esp, &diff, None).context("applying filesystem changes")?; + let esp_devices = self + .get_all_esp_devices() + .expect("get esp devices before adopt"); + let sysroot = sysroot.recover_path()?; + + for esp_dev in esp_devices { + let dest_path = if let Some(dest_path) = self.check_existing_esp(&sysroot)? { + dest_path.join("EFI") + } else { + self.ensure_mounted_esp(&sysroot, &esp_dev)?.join("EFI") + }; + + let esp = openat::Dir::open(&dest_path).context("opening EFI dir")?; + validate_esp(&esp)?; + + // For adoption, we should only touch files that we know about. + let diff = updatef.relative_diff_to(&esp)?; + log::trace!("applying adoption diff: {}", &diff); + filetree::apply_diff(&updated, &esp, &diff, None) + .context("applying filesystem changes")?; + self.unmount().context("unmount after adopt")?; + } Ok(InstalledContent { meta: updatemeta.clone(), filetree: Some(updatef), @@ -300,9 +317,18 @@ impl Component for Efi { log::debug!("Found metadata {}", meta.version); let srcdir_name = component_updatedirname(self); let ft = crate::filetree::FileTree::new_from_dir(&src_root.sub_dir(&srcdir_name)?)?; - let destdir = &self.ensure_mounted_esp(Path::new(dest_root))?; - let destd = &openat::Dir::open(destdir) + let destdir = if let Some(destdir) = self.check_existing_esp(dest_root)? { + destdir + } else { + let esp_device = self + .get_esp_device() + .ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?; + let esp_device = esp_device.to_str().unwrap(); + self.ensure_mounted_esp(dest_root, esp_device)? + }; + + let destd = &openat::Dir::open(&destdir) .with_context(|| format!("opening dest dir {}", destdir.display()))?; validate_esp(destd)?; @@ -344,12 +370,25 @@ impl Component for Efi { .context("opening update dir")?; let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; let diff = currentf.diff(&updatef)?; - self.ensure_mounted_esp(Path::new("/"))?; - let destdir = self.open_esp().context("opening EFI dir")?; - validate_esp(&destdir)?; - log::trace!("applying diff: {}", &diff); - filetree::apply_diff(&updated, &destdir, &diff, None) - .context("applying filesystem changes")?; + let esp_devices = self + .get_all_esp_devices() + .context("get esp devices when running update")?; + let sysroot = sysroot.recover_path()?; + + for esp in esp_devices { + let dest_path = if let Some(dest_path) = self.check_existing_esp(&sysroot)? { + dest_path.join("EFI") + } else { + self.ensure_mounted_esp(&sysroot, &esp)?.join("EFI") + }; + + let destdir = openat::Dir::open(&dest_path).context("opening EFI dir")?; + validate_esp(&destdir)?; + log::trace!("applying diff: {}", &diff); + filetree::apply_diff(&updated, &destdir, &diff, None) + .context("applying filesystem changes")?; + self.unmount().context("unmount after update")?; + } let adopted_from = None; Ok(InstalledContent { meta: updatemeta, @@ -397,24 +436,36 @@ impl Component for Efi { } fn validate(&self, current: &InstalledContent) -> Result { - if !is_efi_booted()? && self.get_esp_device().is_none() { + let esp_devices = self.get_all_esp_devices(); + if !is_efi_booted()? && esp_devices.is_none() { return Ok(ValidationResult::Skip); } let currentf = current .filetree .as_ref() .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; - self.ensure_mounted_esp(Path::new("/"))?; - let efidir = self.open_esp()?; - let diff = currentf.relative_diff_to(&efidir)?; let mut errs = Vec::new(); - for f in diff.changes.iter() { - errs.push(format!("Changed: {}", f)); - } - for f in diff.removals.iter() { - errs.push(format!("Removed: {}", f)); + let esps = esp_devices.ok_or_else(|| anyhow::anyhow!("No esp device found!"))?; + let dest_root = Path::new("/"); + for esp_dev in esps.iter() { + let dest_path = if let Some(dest_path) = self.check_existing_esp(dest_root)? { + dest_path.join("EFI") + } else { + self.ensure_mounted_esp(dest_root, &esp_dev)?.join("EFI") + }; + + let efidir = openat::Dir::open(dest_path.as_path())?; + let diff = currentf.relative_diff_to(&efidir)?; + + for f in diff.changes.iter() { + errs.push(format!("Changed: {}", f)); + } + for f in diff.removals.iter() { + errs.push(format!("Removed: {}", f)); + } + assert_eq!(diff.additions.len(), 0); + self.unmount().context("unmount after validate")?; } - assert_eq!(diff.additions.len(), 0); if !errs.is_empty() { Ok(ValidationResult::Errors(errs)) } else { diff --git a/src/main.rs b/src/main.rs index 7c7cb40c..ac85381f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ Refs: mod backend; #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] mod bios; +mod blockdev; mod bootupd; mod cli; mod component; diff --git a/tests/fixtures/example-lsblk-output.json b/tests/fixtures/example-lsblk-output.json index f0aac3e0..b506a937 100644 --- a/tests/fixtures/example-lsblk-output.json +++ b/tests/fixtures/example-lsblk-output.json @@ -3,30 +3,37 @@ { "path": "/dev/sr0", "pttype": null, + "parttype": null, "parttypename": null },{ "path": "/dev/zram0", "pttype": null, + "parttype": null, "parttypename": null },{ "path": "/dev/vda", "pttype": "gpt", + "parttype": null, "parttypename": null },{ "path": "/dev/vda1", "pttype": "gpt", + "parttype": null, "parttypename": "EFI System" },{ "path": "/dev/vda2", "pttype": "gpt", + "parttype": null, "parttypename": "Linux extended boot" },{ "path": "/dev/vda3", "pttype": "gpt", + "parttype": null, "parttypename": "Linux filesystem" },{ "path": "/dev/mapper/luks-df2d5f95-5725-44dd-83e1-81bc4cdc49b8", "pttype": null, + "parttype": null, "parttypename": null } ]