diff --git a/src/bios.rs b/src/bios.rs index f8c644e4..5f868dc3 100644 --- a/src/bios.rs +++ b/src/bios.rs @@ -2,67 +2,19 @@ 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 {} impl Bios { - // get target device for running update - fn get_device(&self) -> Result { - let mut cmd: Command; - #[cfg(target_arch = "x86_64")] - { - // find /boot partition - cmd = Command::new("findmnt"); - cmd.arg("--noheadings") - .arg("--nofsroot") - .arg("--output") - .arg("SOURCE") - .arg("/boot"); - let partition = util::cmd_output(&mut cmd)?; - - // lsblk to find parent device - cmd = Command::new("lsblk"); - cmd.arg("--paths") - .arg("--noheadings") - .arg("--output") - .arg("PKNAME") - .arg(partition.trim()); - } - - #[cfg(target_arch = "powerpc64")] - { - // get PowerPC-PReP-boot partition - cmd = Command::new("realpath"); - cmd.arg("/dev/disk/by-partlabel/PowerPC-PReP-boot"); - } - - let device = util::cmd_output(&mut cmd)?; - Ok(device) - } - // Return `true` if grub2-modules installed fn check_grub_modules(&self) -> Result { let usr_path = Path::new("/usr/lib/grub"); @@ -115,37 +67,13 @@ impl Bios { } // check bios_boot partition on gpt type disk - fn get_bios_boot_partition(&self) -> Result> { - let target = self.get_device()?; - // 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)); - } - } + fn get_bios_boot_partition(&self) -> Option> { + let bios_boot_devices = + blockdev::find_colocated_bios_boot("/").expect("get bios_boot devices"); + if !bios_boot_devices.is_empty() { + return Some(bios_boot_devices); } - Ok(None) + return None; } } @@ -187,7 +115,7 @@ impl Component for Bios { fn query_adopt(&self) -> Result> { #[cfg(target_arch = "x86_64")] - if crate::efi::is_efi_booted()? && self.get_bios_boot_partition()?.is_none() { + if crate::efi::is_efi_booted()? && self.get_bios_boot_partition().is_none() { log::debug!("Skip BIOS adopt"); return Ok(None); } @@ -199,9 +127,12 @@ impl Component for Bios { anyhow::bail!("Failed to find adoptable system") }; - let device = self.get_device()?; - let device = device.trim(); - self.run_grub_install("/", device)?; + let target_root = "/"; + let devices = blockdev::get_backing_devices(&target_root)?; + for dev in devices.iter() { + self.run_grub_install(target_root, dev)?; + log::debug!("Install grub2 on {dev}"); + } Ok(InstalledContent { meta: update.clone(), filetree: None, @@ -215,9 +146,13 @@ impl Component for Bios { fn run_update(&self, sysroot: &openat::Dir, _: &InstalledContent) -> Result { let updatemeta = self.query_update(sysroot)?.expect("update available"); - let device = self.get_device()?; - let device = device.trim(); - self.run_grub_install("/", device)?; + let sysroot = sysroot.recover_path()?; + let dest_root = sysroot.to_str().unwrap_or("/"); + let devices = blockdev::get_backing_devices(&dest_root)?; + for dev in devices.iter() { + self.run_grub_install(dest_root, dev)?; + log::debug!("Install grub2 on {dev}"); + } let adopted_from = None; Ok(InstalledContent { @@ -235,18 +170,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..ca9d3132 --- /dev/null +++ b/src/blockdev.rs @@ -0,0 +1,205 @@ +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 +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(|| "while looking for colocated ESPs")?; + + // now, look for all ESPs 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..bc902974 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,29 @@ impl Efi { return esp_device; } - pub(crate) fn ensure_mounted_esp(&self, root: &Path) -> Result { + 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; + } + + 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 { let mnt = root.join(mnt); if !mnt.exists() { @@ -113,9 +110,6 @@ impl Efi { return Ok(mnt); } - let esp_device = self - .get_esp_device() - .ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?; for &mnt in ESP_MOUNTS.iter() { let mnt = root.join(mnt); if !mnt.exists() { @@ -134,14 +128,13 @@ 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:?}"); } - log::trace!("Unmounted"); + log::trace!("Unmounted {mount:?}"); } Ok(()) } @@ -245,8 +238,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 +261,27 @@ 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 = 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 +303,14 @@ 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 esp_device = self + .get_esp_device() + .ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?; + let esp_device = esp_device.to_str().unwrap(); + let destdir = self.ensure_mounted_esp(dest_root, esp_device)?.join("EFI"); + + let destd = &openat::Dir::open(&destdir) .with_context(|| format!("opening dest dir {}", destdir.display()))?; validate_esp(destd)?; @@ -344,12 +352,20 @@ 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 = 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 +413,30 @@ 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!"))?; + for esp_dev in esps.iter() { + let dest_path = self.ensure_mounted_esp("/", &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 (on {}): {}", esp_dev, f)); + } + for f in diff.removals.iter() { + errs.push(format!("Removed (on {}): {}", esp_dev, 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 } ]