diff --git a/.cci.jenkinsfile b/.cci.jenkinsfile index 0f717f7a..6407d948 100644 --- a/.cci.jenkinsfile +++ b/.cci.jenkinsfile @@ -17,7 +17,6 @@ parallel build: { } stage("Unit tests") { shwrap(""" - dnf install -y grub2-tools-minimal cargo test --features rpm cargo test """) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15ecf24c..83c9f90f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,16 +18,33 @@ env: CARGO_TERM_COLOR: always jobs: - c9s-bootc-e2e: + bootc-e2e: strategy: matrix: runner: - ubuntu-24.04 - ubuntu-24.04-arm + grubcc: + - 0 + - 1 runs-on: [ "${{ matrix.runner }}" ] steps: + - name: Setup env + run: | + IMG_NAME=localhost/bootupd-grubcc-${{ matrix.grubcc }} + echo "IMG_NAME=${IMG_NAME}" >> $GITHUB_ENV + + if [[ "${{ matrix.grubcc }}" == "1" ]]; then + # we only have grub-cc in feodra (for now) + OS_ID="fedora" + else + OS_ID="centos" + fi + + echo "OS_ID=${OS_ID}" >> $GITHUB_ENV + - name: Get a newer podman for heredoc support (from debian testing) run: | set -eux @@ -51,14 +68,26 @@ jobs: sudo apt update -y sudo apt install -y podman - - name: build - run: sudo podman build -t localhost/bootupd:latest -f Dockerfile . + - name: build with grubcc=${{ matrix.grubcc }} + run: | + dockerfile=Dockerfile + build_args=() + + if [[ "${{ matrix.grubcc }}" == "1" ]]; then + dockerfile=Dockerfile.bls + build_args=(--build-arg "grubcc=${{ matrix.grubcc }}") + fi + + sudo podman build "${build_args[@]}" . -t "$IMG_NAME" -f "$dockerfile" - name: bootupctl status in container run: | set -xeuo pipefail - sudo podman run --rm -v $PWD:/run/src -w /run/src --privileged localhost/bootupd:latest tests/tests/bootupctl-status-in-bootc.sh + sudo podman run --rm -v $PWD:/run/src -w /run/src --privileged "$IMG_NAME" tests/tests/bootupctl-status-in-bootc.sh + # Make sure grub stuff works with and without grubcc in the container image + # TODO: Test GrubCC installation with composefs after a bootc release includes + # https://github.com/bootc-dev/bootc/pull/2223 - name: bootc install to disk run: | set -xeuo pipefail @@ -66,7 +95,7 @@ jobs: sudo podman run --rm --privileged -v .:/target --pid=host --security-opt label=disable \ -v /var/lib/containers:/var/lib/containers \ -v /dev:/dev \ - localhost/bootupd:latest bootc install to-disk --skip-fetch-check \ + "$IMG_NAME" bootc install to-disk --filesystem ext4 --skip-fetch-check \ --disable-selinux --generic-image --via-loopback /target/myimage.raw # Verify we installed grub.cfg and shim on the disk sudo losetup -P -f myimage.raw @@ -80,7 +109,7 @@ jobs: # Assume aarch64 for now shim="shimaa64.efi" fi - sudo ls /mnt/EFI/centos/{grub.cfg,${shim}} + sudo ls /mnt/EFI/$OS_ID/{grub.cfg,${shim}} sudo umount /mnt # check /boot/grub2/grub.cfg permission root_part=$(sudo sfdisk -l -J "${device}" | jq -r '.partitiontable.partitions[] | select(.name == "root").node') @@ -99,7 +128,7 @@ jobs: set -xeuo pipefail sudo podman run --rm -ti --privileged -v /:/target --pid=host --security-opt label=disable \ -v /dev:/dev -v /var/lib/containers:/var/lib/containers \ - localhost/bootupd:latest env BOOTC_BOOTLOADER_DEBUG=1 \ + "$IMG_NAME" env BOOTC_BOOTLOADER_DEBUG=1 \ bootc install to-filesystem --skip-fetch-check \ --acknowledge-destructive \ --disable-selinux --replace=alongside /target @@ -113,4 +142,14 @@ jobs: - name: bootupctl generate-update-metadata run: | set -xeuo pipefail - sudo podman run --rm -v $PWD:/run/src -w /run/src --privileged localhost/bootupd:latest tests/tests/generate-update-metadata.sh + sudo podman run --rm -v $PWD:/run/src -w /run/src --privileged "$IMG_NAME" tests/tests/generate-update-metadata.sh + + - name: Test GrubCC and SystemdBoot + run: | + if [[ "${{ matrix.grubcc }}" == "0" ]]; then + exit 0 + fi + + set -xeuo pipefail + sudo ./scripts/test-bootloader.sh "$IMG_NAME" "grub-cc" + sudo ./scripts/test-bootloader.sh "$IMG_NAME" "systemd" diff --git a/Cargo.lock b/Cargo.lock index d3e4f035..1d8ebe35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,7 @@ version = "0.2.35" dependencies = [ "anyhow", "bootc-internal-blockdev", + "bootc-internal-mount", "bootc-internal-utils", "camino", "cap-std", diff --git a/Cargo.toml b/Cargo.toml index ee4b1ee4..b6e24721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ path = "src/main.rs" anyhow = "1.0" bootc-internal-blockdev = "1.16.0" bootc-internal-utils = "1.16.0" +bootc-internal-mount = "1.16.0" cap-std-ext = "5.0.0" camino = "1.2.2" chrono = { version = "0.4.44", features = ["serde"] } diff --git a/Dockerfile.bls b/Dockerfile.bls new file mode 100644 index 00000000..a1693562 --- /dev/null +++ b/Dockerfile.bls @@ -0,0 +1,125 @@ +# Dockerfile to test GrubCC and systemd-boot +# Build from the current git into a fedora44 container image. + +ARG base=quay.io/fedora/fedora-bootc:44 + +FROM $base as build +# This installs our package dependencies, and we want to cache it independently of the rest. +# Basically we don't want changing a .rs file to blow out the cache of packages. +RUN <&2 + exit 1 + ;; +esac + +mv ./*.rpm grub-cc.rpm + +EOF + +FROM $base +# Clean out the default to ensure we're using our updated content +RUN rpm -e bootupd +COPY --from=build /out/ / +COPY --from=grub-cc-download /grub-cc.rpm /var/grub-cc.rpm +# Install bootc from copr +RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp </EFI/fedora/ +mkdir -p "/usr/lib/efi/systemd-boot/${evr}/EFI/$ID" + +if [[ $(uname -m) == x86_64 ]]; then + grubName="grubx64.efi" +else + grubName="grubaa64.efi" +fi + +cp /usr/lib/systemd/boot/efi/systemd-boot*.efi "/usr/lib/efi/systemd-boot/${evr}/EFI/$ID/$grubName" + +EOF + +# Install grub-cc +RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp </EFI/fedora/ +mkdir -p "/usr/lib/efi/grub-cc/${evr}/EFI/$ID/" +cp "$file" "/usr/lib/efi/grub-cc/${evr}/EFI/$ID/$grubName" + +cleanup + +# Regenerate metadata +bootupctl backend generate-update-metadata -vvv + +# Set grub as default +bootupctl backend set-default-bootloader grub + +EOF + +# Sanity check this too +RUN bootc container lint --fatal-warnings diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..be5cb350 --- /dev/null +++ b/build.rs @@ -0,0 +1,11 @@ +fn main() { + println!("cargo::rustc-check-cfg=cfg(efi_arch)"); + + if cfg!(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + )) { + println!("cargo:rustc-cfg=efi_arch"); + } +} diff --git a/scripts/test-bootloader.sh b/scripts/test-bootloader.sh new file mode 100755 index 00000000..1fe645ff --- /dev/null +++ b/scripts/test-bootloader.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -eux + +IMG_NAME=$1 +BOOTLOADER=$2 + +case $BOOTLOADER in + systemd) + EFI_DIR_NAME=systemd-boot + ;; + grub-cc) + EFI_DIR_NAME=grub-cc + ;; + grub) + EFI_DIR_NAME=grub2 + ;; + *) + echo "Unknown bootloader $BOOTLOADER" + exit 1 + ;; +esac + +cat <<-EOF > sfdisk-buf +label: gpt +label-id: 65be9332-59ba-11f1-9b26-6a8e2ab625e4 +size=1Gib, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="EFI-SYSTEM" + type=4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709, name="root" +EOF + +truncate -s4G "${BOOTLOADER}-test.img" + +cat sfdisk-buf | sfdisk --wipe=always "${BOOTLOADER}-test.img" + +mkdir -p /var/mnt + +# Also update kernel partition tables +loopdev=$(losetup --find --show --partscan "${BOOTLOADER}-test.img") +sleep 1 + +mkfs.vfat "${loopdev}p1" +mkfs.ext4 "${loopdev}p2" + +mount "${loopdev}p2" /var/mnt + +ESP="/var/mnt/efi" + +mkdir -p $ESP +mount "${loopdev}p1" $ESP + + +# Test installing the bootloader +podman run --rm --net=host --privileged --pid=host \ + --privileged \ + --security-opt label=type:unconfined_t \ + --env RUST_LOG=trace \ + -v /dev:/dev \ + -v /var/mnt:/var/mnt \ + "$IMG_NAME" \ + bootupctl backend install --bootloader "$BOOTLOADER" /var/mnt -vvvv + +# Make sure bootupd-state.json is in the esp +test -f "$ESP/bootupd-state.json" + +cat "$ESP/bootupd-state.json" | jq + +version=$(cat "$ESP/bootupd-state.json" | jq -r ".installed.EFI.meta.version") + +if [[ $version != *shim* ]]; then echo "shim not found in version"; exit 1; fi +if [[ $version != *"$EFI_DIR_NAME"* ]]; then echo "$BOOTLOADER not found in version"; exit 1; fi + +# Test if the correct binary has been installed +actualShasum=$(podman run --rm "$IMG_NAME" find "/usr/lib/efi/$EFI_DIR_NAME" -type f -exec sha512sum {} + | awk '{print $1}') +actualShasum="sha512:$actualShasum" + +if [[ $(uname -m) == "x86_64" ]]; then + grubName="grubx64.efi" +else + grubName="grubaa64.efi" +fi + +# TODO: Remove hardcoded "fedora" once we have support in centos +storedShasum=$(cat "$ESP/bootupd-state.json" | jq -r --arg grub "$grubName" '.installed.EFI.filetree.children["fedora/\($grub)"].sha512') + +test "$actualShasum" == "$storedShasum" + +efiBinShasum=$(find "$ESP" -type f -name "$grubName" -exec sha512sum {} + | awk '{print $1}') +efiBinShasum="sha512:$efiBinShasum" + +test "$efiBinShasum" == "$actualShasum" + +umount -Rl /var/mnt diff --git a/src/backend/statefile.rs b/src/backend/statefile.rs index 4f7bef26..a81dba58 100644 --- a/src/backend/statefile.rs +++ b/src/backend/statefile.rs @@ -1,8 +1,13 @@ //! On-disk saved state. +use crate::bootloader::Bootloader; +use crate::bootupd::list_dev_current_root; + use crate::model::SavedState; use crate::util::SignalTerminationGuard; use anyhow::{bail, Context, Result}; +use bootc_internal_blockdev::Device; +use camino::Utf8PathBuf; use cap_std::ambient_authority; use cap_std::fs::{Dir, Permissions, PermissionsExt}; use cap_std_ext::dirext::CapStdExtDirExt; @@ -12,12 +17,59 @@ use std::fs::File; use std::io::prelude::*; use std::path::Path; +fn parse_statefile(statusf: cap_std::fs::File) -> Result> { + let mut bufr = std::io::BufReader::new(statusf); + let mut s = String::new(); + bufr.read_to_string(&mut s)?; + let state: serde_json::Result = serde_json::from_str(s.as_str()); + + let r = match state { + Ok(s) => s, + Err(orig_err) => { + let state: serde_json::Result = + serde_json::from_str(s.as_str()); + match state { + Ok(s) => s.upconvert(), + Err(_) => { + return Err(orig_err.into()); + } + } + } + }; + + Ok(Some(r)) +} + +/// lsblk: composefs:abc123..: not a block device +/// is what lsblk throws on composefs booted systems if we try to +/// get block devices using "/" +/// +/// First, try to get the device from the `root` which is necessary +/// during installs as we don't want (or can't) to open up /sysroot or /boot +/// +/// If that fails, it means we're not on the install path so we get the +/// device from checking mount point from /boot or /sysroot +#[context("Getting parent device")] +fn get_parent_device(root: &Dir) -> Result { + let root_fs = bootc_internal_mount::inspect_filesystem_of_dir(root) + .context("Inspecting root filesystem")?; + + if root_fs.fstype == "overlay" && root_fs.source.contains("composefs") { + // Root is mounted as overlay composefs, lsblk will throw an error + // Ergo, find backing device by looking at mountpoints for /sysroot | /boot + return list_dev_current_root(); + } + + return bootc_internal_blockdev::list_dev_by_dir(root); +} + impl SavedState { /// System-wide bootupd write lock (relative to sysroot). const WRITE_LOCK_PATH: &'static str = "run/bootupd-lock"; /// Top-level directory for statefile (relative to sysroot). pub(crate) const STATEFILE_DIR: &'static str = "boot"; - /// On-disk bootloader statefile, akin to a tiny rpm/dpkg database, stored in `/boot`. + /// On-disk bootloader statefile, akin to a tiny rpm/dpkg database, + /// stored in `/boot` for Grub and in `ESP` for GrubCC pub(crate) const STATEFILE_NAME: &'static str = "bootupd-state.json"; /// Try to acquire a system-wide lock to ensure non-conflicting state updates. @@ -25,7 +77,10 @@ impl SavedState { /// While ordinarily the daemon runs as a systemd unit (which implicitly /// ensures a single instance) this is a double check against other /// execution paths. - pub(crate) fn acquire_write_lock(sysroot: Dir) -> Result { + pub(crate) fn acquire_write_lock( + sysroot_path: Utf8PathBuf, + sysroot: Dir, + ) -> Result { sysroot .atomic_write_with_perms(Self::WRITE_LOCK_PATH, "", Permissions::from_mode(0o644)) .context("Creating lock file")?; @@ -38,6 +93,7 @@ impl SavedState { lockfile.lock_exclusive().context("Acquiring lock")?; let guard = StateLockGuard { + sysroot_path, sysroot, termguard: Some(SignalTerminationGuard::new()?), lockfile: Some(lockfile), @@ -47,8 +103,9 @@ impl SavedState { /// Use this for cases when the target root isn't booted, which is /// offline installs. - pub(crate) fn unlocked(sysroot: Dir) -> Result { + pub(crate) fn unlocked(sysroot_path: Utf8PathBuf, sysroot: Dir) -> Result { Ok(StateLockGuard { + sysroot_path, sysroot, termguard: None, lockfile: None, @@ -57,52 +114,91 @@ impl SavedState { /// Load the JSON file containing on-disk state. #[context("Loading saved state")] - pub(crate) fn load_from_disk(root_path: impl AsRef) -> Result> { + pub(crate) fn load_from_disk( + root_path: impl AsRef, + bootloader: Option, + ) -> Result> { let root_path = root_path.as_ref(); - let sysroot = Dir::open_ambient_dir(root_path, ambient_authority()) + + let root = Dir::open_ambient_dir(root_path, ambient_authority()) .with_context(|| format!("opening sysroot '{}'", root_path.display()))?; - let statefile_path = Path::new(Self::STATEFILE_DIR).join(Self::STATEFILE_NAME); - let saved_state = if let Some(statusf) = sysroot.open_optional(&statefile_path)? { - let mut bufr = std::io::BufReader::new(statusf); - let mut s = String::new(); - bufr.read_to_string(&mut s)?; - let state: serde_json::Result = serde_json::from_str(s.as_str()); - let r = match state { - Ok(s) => s, - Err(orig_err) => { - let state: serde_json::Result = - serde_json::from_str(s.as_str()); - match state { - Ok(s) => s.upconvert(), - Err(_) => { - return Err(orig_err.into()); - } + match bootloader { + Some(b) => match b { + Bootloader::Grub => { + let path = Path::new(Self::STATEFILE_DIR).join(Self::STATEFILE_NAME); + + match root.open_optional(&path)? { + Some(f) => parse_statefile(f), + None => Ok(None), } } - }; - Some(r) - } else { - None - }; - Ok(saved_state) + + #[cfg(efi_arch)] + Bootloader::GrubCC | Bootloader::Systemd => { + use crate::efi::Efi; + let efi = Efi::default(); + + let device = get_parent_device(&root)?; + + // Since we write the state file to all ESPs, it should be enough to get it + // from the first one. Though, we could check the integrity by getting from + // all the ESPs and making sure they're all the same... + let esp = device.find_first_colocated_esp()?; + + // According to BLS, the ESP should be mounted at /boot or /boot/efi + // which the following method already checks + let mounted = efi + .ensure_mounted_esp(root_path, Path::new(&esp.path())) + .context("Mounting ESP")?; + + let dir = Dir::open_ambient_dir(&mounted, ambient_authority())?; + + match dir.open_optional(Self::STATEFILE_NAME)? { + Some(f) => parse_statefile(f), + None => Ok(None), + } + } + }, + + // No bootloader, we're probably running inside a container + None => Ok(None), + } } /// Check whether statefile exists. - pub(crate) fn ensure_not_present(root_path: impl AsRef) -> Result<()> { - let statepath = Path::new(root_path.as_ref()) - .join(Self::STATEFILE_DIR) - .join(Self::STATEFILE_NAME); - if statepath.exists() { - bail!("{} already exists", statepath.display()); + pub(crate) fn ensure_not_present( + root_path: impl AsRef, + bootloader: Bootloader, + ) -> Result<()> { + let saved_state = SavedState::load_from_disk(&root_path, Some(bootloader))?; + + if saved_state.is_none() { + return Ok(()); + } + + match bootloader { + Bootloader::Grub => { + let statepath = Path::new(root_path.as_ref()) + .join(Self::STATEFILE_DIR) + .join(Self::STATEFILE_NAME); + + bail!("{} already exists", statepath.display()); + } + + #[cfg(efi_arch)] + Bootloader::GrubCC | Bootloader::Systemd => { + bail!("{} already exists in the ESP", Self::STATEFILE_NAME); + } } - Ok(()) } } /// Write-lock guard for statefile, protecting against concurrent state updates. #[derive(Debug)] pub(crate) struct StateLockGuard { + #[allow(dead_code)] + pub(crate) sysroot_path: Utf8PathBuf, pub(crate) sysroot: Dir, #[allow(dead_code)] termguard: Option, @@ -111,8 +207,7 @@ pub(crate) struct StateLockGuard { } impl StateLockGuard { - /// Atomically replace the on-disk state with a new version. - pub(crate) fn update_state(&mut self, state: &SavedState) -> Result<()> { + fn write_grub_statefile(&self, state: &SavedState) -> Result<()> { let subdir = self.sysroot.open_dir(SavedState::STATEFILE_DIR)?; subdir @@ -123,6 +218,65 @@ impl StateLockGuard { ) .context("Writing state file")?; + return Ok(()); + } + + #[context("Updating state")] + #[cfg(not(efi_arch))] + pub(crate) fn update_state( + &mut self, + state: &SavedState, + _bootloader: Bootloader, + ) -> Result<()> { + return self.write_grub_statefile(state); + } + + /// Atomically replace the on-disk state with a new version. + #[context("Updating state")] + #[cfg(efi_arch)] + pub(crate) fn update_state( + &mut self, + state: &SavedState, + bootloader: Bootloader, + ) -> Result<()> { + if bootloader == Bootloader::Grub { + return self.write_grub_statefile(state); + } + + use crate::efi::Efi; + use crate::freezethaw::fsfreeze_thaw_cycle; + + let device = get_parent_device(&self.sysroot)?; + let all_esps = device + .find_colocated_esps() + .context("Searching for ESP")? + .ok_or_else(|| anyhow::anyhow!("ESP not found"))?; + + let serialized_state = serde_json::to_vec(state).context("Serializing state")?; + + for esp in all_esps { + let efi = Efi::default(); + + let mounted = efi + .ensure_mounted_esp(self.sysroot_path.as_std_path(), Path::new(&esp.path())) + .context("Mounting ESP")?; + + let dir = Dir::open_ambient_dir(&mounted, ambient_authority())?; + + dir.atomic_write_with_perms( + SavedState::STATEFILE_NAME, + &serialized_state, + Permissions::from_mode(0o644), + ) + .context("Writing state file")?; + + // Do the sync before unmount + fsfreeze_thaw_cycle(dir.reopen_as_ownedfd()?)?; + drop(dir); + // This takes care of not unmounting ESP if it was already mounted + drop(efi); + } + Ok(()) } } diff --git a/src/bios.rs b/src/bios.rs index 1ee0b51a..03a34085 100644 --- a/src/bios.rs +++ b/src/bios.rs @@ -9,6 +9,7 @@ use std::process::Command; use bootc_internal_blockdev::Device; +use crate::bootloader::Bootloader; use crate::bootupd::RootContext; use crate::component::*; use crate::freezethaw::fsfreeze_thaw_cycle; @@ -99,7 +100,15 @@ impl Bios { impl Component for Bios { fn name(&self) -> &'static str { - "BIOS" + self.component_type().into() + } + + fn component_type(&self) -> ComponentType { + ComponentType::Bios + } + + fn is_bootloader_supported(&self, bootloader: Bootloader) -> bool { + matches!(bootloader, Bootloader::Grub) } fn install( @@ -108,12 +117,17 @@ impl Component for Bios { dest_root: &str, device: Option<&Device>, _update_firmware: bool, + bootloader: Bootloader, ) -> Result { + if !self.is_bootloader_supported(bootloader) { + anyhow::bail!("{bootloader} cannot be installed for {}", self.name()); + } + let device = device.ok_or_else(|| anyhow::anyhow!("BIOS component requires a target device"))?; let src_dir = Dir::open_ambient_dir(src_root, ambient_authority()) .with_context(|| format!("opening source directory {src_root}"))?; - let Some(meta) = get_component_update(&src_dir, self)? else { + let Some(meta) = self.get_component_update(&src_dir, Some(bootloader))? else { anyhow::bail!("No update metadata for component {} found", self.name()); }; @@ -136,7 +150,7 @@ impl Component for Bios { // Query the rpm database and list the package and build times for /usr/sbin/grub2-install let meta = packagesystem::query_files(sysroot_path, [&grub_install])?; - write_update_metadata(sysroot_path, self, &meta)?; + write_update_metadata(sysroot_path, self.component_update_data_name(), &meta)?; Ok(Some(meta)) } @@ -245,8 +259,16 @@ impl Component for Bios { })) } - fn query_update(&self, sysroot: &Dir) -> Result> { - get_component_update(sysroot, self) + fn query_update( + &self, + sysroot: &Dir, + bootloader: Bootloader, + ) -> Result> { + if !self.is_bootloader_supported(bootloader) { + return Ok(None); + } + + self.get_component_update(sysroot, Some(bootloader)) } fn query_requires_update(&self, sysroot: &Dir) -> Result<()> { @@ -259,7 +281,7 @@ impl Component for Bios { fn run_update(&self, rootcxt: &RootContext, _: &InstalledContent) -> Result { let updatemeta = self - .query_update(&rootcxt.sysroot)? + .query_update(&rootcxt.sysroot, Bootloader::Grub)? .expect("update available"); for parent in rootcxt.device.find_all_roots()? { diff --git a/src/bootloader.rs b/src/bootloader.rs new file mode 100644 index 00000000..f5a4b97b --- /dev/null +++ b/src/bootloader.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use fn_error_context::context; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +#[derive(Debug, Copy, Clone, clap::ValueEnum, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Bootloader { + Grub, + #[cfg(efi_arch)] + GrubCC, + #[cfg(efi_arch)] + Systemd, +} + +impl Display for Bootloader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Bootloader::Grub => f.write_str("grub"), + #[cfg(efi_arch)] + Bootloader::GrubCC => f.write_str("grub-cc"), + #[cfg(efi_arch)] + Bootloader::Systemd => f.write_str("systemd"), + } + } +} + +impl Bootloader { + #[cfg(efi_arch)] + fn next(self) -> Option { + match self { + Self::Grub => Some(Self::GrubCC), + Self::GrubCC => Some(Self::Systemd), + Self::Systemd => None, + } + } + + #[cfg(not(efi_arch))] + fn next(self) -> Option { + match self { + Self::Grub => None, + } + } + + pub(crate) fn iter() -> impl Iterator { + std::iter::successors(Some(Self::Grub), |v| v.next()) + } + + /// Returns the name of the EFI component for this particular bootloader + /// We use directories inside /usr/lib/efi as values of EFI component + /// + /// Example + /// /usr/lib/efi/ + /// |-- grub-cc + /// |-- grub2 + /// `-- shim + pub(crate) fn efi_component_name(&self) -> &'static str { + match self { + Bootloader::Grub => "grub2", + #[cfg(efi_arch)] + Bootloader::GrubCC => "grub-cc", + #[cfg(efi_arch)] + Bootloader::Systemd => "systemd-boot", + } + } + + #[cfg(efi_arch)] + pub(crate) fn try_from_efi_component_name(component_name: &str) -> Result { + match component_name { + "grub2" => Ok(Self::Grub), + #[cfg(efi_arch)] + "grub-cc" => Ok(Self::GrubCC), + #[cfg(efi_arch)] + "systemd-boot" => Ok(Self::Systemd), + _ => anyhow::bail!("Not a valid bootloader: {component_name}"), + } + } +} + +#[cfg(not(efi_arch))] +#[context("Getting bootloader")] +pub(crate) fn get_bootloader() -> Result { + Ok(Bootloader::Grub) +} + +#[cfg(efi_arch)] +#[context("Getting bootloader")] +pub(crate) fn get_bootloader() -> Result { + use crate::efi::get_loader_info; + use std::sync::OnceLock; + + static BOOTLOADER: OnceLock = OnceLock::new(); + + if let Some(bootloader) = BOOTLOADER.get() { + return Ok(*bootloader); + } + + let bootloader = match get_loader_info() { + Some(info) => match info.to_lowercase() { + i if i.contains("grub cc") => Bootloader::GrubCC, + i if i.contains("systemd-boot") => Bootloader::Systemd, + _ => Bootloader::Grub, + }, + None => Bootloader::Grub, + }; + + BOOTLOADER.get_or_init(|| bootloader); + + return Ok(bootloader); +} diff --git a/src/bootupd.rs b/src/bootupd.rs index 16a49138..733148e3 100644 --- a/src/bootupd.rs +++ b/src/bootupd.rs @@ -1,6 +1,8 @@ #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] use crate::bios; -use crate::component; +use crate::bootloader::{get_bootloader, Bootloader}; +use crate::cli::bootupd::InstallOpts; +use crate::component::{self, ComponentType}; use crate::component::{Component, ValidationResult}; use crate::coreos; #[cfg(any( @@ -52,28 +54,30 @@ impl ConfigMode { } } -pub(crate) fn install( - source_root: &str, - dest_root: &str, - devices: &[Device], - configs: ConfigMode, - update_firmware: bool, - target_components: Option<&[String]>, - auto_components: bool, -) -> Result<()> { - let source_root_dir = - Dir::open_ambient_dir(source_root, ambient_authority()).context("Opening source root")?; - SavedState::ensure_not_present(dest_root) - .context("failed to install, invalid re-install attempted")?; +pub(crate) fn install(opts: &InstallOpts, devices: &[Device], configs: ConfigMode) -> Result<()> { + // SavedState needs to be per component + // Consider this scenario: + // - Grub installed (statefile in /sysroot/boot) + // - Re-install attempted with GrubCC + // + // So we can't just check statefile based on the determined bootloader + // We need to check all cases + for b in Bootloader::iter() { + SavedState::ensure_not_present(&opts.dest_root, b) + .context("failed to install, invalid re-install attempted")?; + } + + let source_root_dir = Dir::open_ambient_dir(&opts.src_root, ambient_authority()) + .context("Opening source root")?; - let all_components = get_components_impl(auto_components); + let all_components = get_components_impl(opts.auto); if all_components.is_empty() { println!("No components available for this platform."); return Ok(()); } - let target_components = if let Some(target_components) = target_components { + let target_components = if let Some(target_components) = &opts.components { // Checked by CLI parser - assert!(!auto_components); + assert!(!opts.auto); target_components .iter() .map(|name| { @@ -86,15 +90,80 @@ pub(crate) fn install( all_components.values().collect() }; - if target_components.is_empty() && !auto_components { + if target_components.is_empty() && !opts.auto { anyhow::bail!("No components specified"); } + #[cfg(efi_arch)] + let bootloader = match opts.bootloader { + // CLI overrides anything else + Some(b) => b, + None => { + let mut efi_default = None; + let mut efi_component_update = None; + let mut bios_default = None; + + for c in &target_components { + use crate::component::ComponentType; + + match c.component_type() { + ComponentType::Bios => { + bios_default = Some(Bootloader::Grub); + } + ComponentType::Efi => { + efi_default = c.get_default_bootloader(&source_root_dir)?; + + if efi_default.is_none() { + // We don't want to filter any bootloader + efi_component_update = + c.get_component_update(&source_root_dir, None)?; + } + } + } + } + + match (bios_default, efi_default) { + // EFI bootloader takes precedence + // Take the following example + // - BIOS default = Grub (always) + // - EFI default = GrubCC + // + // We can't install GrubCC for BIOS as it's not supported + // So we just default to installing GrubCC + (_, Some(eb)) => eb, + (Some(bb), None) => bb, + + // We still can get the bootloader by reading in the EFI component update + // If there's only ONE bootloader in the metadata, then that's the one to + // be installed + (None, None) => { + let Some(efi_component_update) = efi_component_update else { + anyhow::bail!("Could not determine bootloader. Default bootloader not set") + }; + + let available_bootloaders = efi_component_update.available_bootloaders(); + + if available_bootloaders.len() != 1 { + anyhow::bail!( + "Could not determine bootloader. Default bootloader not set. Multiple bootloaders found as install candidates" + ) + } + + available_bootloaders[0] + } + } + } + }; + + #[cfg(not(efi_arch))] + let bootloader = Bootloader::Grub; + let mut state = SavedState::default(); let mut installed_efi_vendor = None; + for &component in target_components.iter() { // skip for BIOS if no devices specified - if component.name() == "BIOS" && devices.is_empty() { + if component.component_type() == ComponentType::Bios && devices.is_empty() { println!( "Skip installing component {} without target device", component.name() @@ -102,8 +171,19 @@ pub(crate) fn install( continue; } + if !component.is_bootloader_supported(bootloader) { + println!( + "Skip installing component {} as it does not support bootloader {bootloader}", + component.name() + ); + continue; + } + // skip components that don't have an update metadata - if component.query_update(&source_root_dir)?.is_none() { + if component + .query_update(&source_root_dir, bootloader)? + .is_none() + { println!( "Skip installing component {} without update metadata", component.name() @@ -116,7 +196,7 @@ pub(crate) fn install( let devices_to_install: Vec> = if devices.is_empty() { // No devices specified: install once with auto-detection (None). vec![None] - } else if component.name() == "EFI" { + } else if component.component_type() == ComponentType::Efi { // For EFI, only install to devices that have an ESP partition. let esp_devices: Vec<&Device> = devices .iter() @@ -148,7 +228,13 @@ pub(crate) fn install( for device in &devices_to_install { let device_desc = device.map_or("(auto)".to_string(), |d| d.path()); let meta = component - .install(source_root, dest_root, *device, update_firmware) + .install( + &opts.src_root, + &opts.dest_root, + *device, + opts.update_firmware, + bootloader, + ) .with_context(|| { format!( "installing component {} to device {}", @@ -171,13 +257,13 @@ pub(crate) fn install( } // Yes this is a hack...the Component thing just turns out to be too generic. if installed_efi_vendor.is_none() { - if let Some(vendor) = component.get_efi_vendor(Path::new(source_root))? { + if let Some(vendor) = component.get_efi_vendor(Path::new(&opts.src_root))? { installed_efi_vendor = Some(vendor); } } } } - let sysroot = &Dir::open_ambient_dir(dest_root, ambient_authority())?; + let sysroot = &Dir::open_ambient_dir(&opts.dest_root, ambient_authority())?; #[cfg(any( target_arch = "x86_64", @@ -185,28 +271,30 @@ pub(crate) fn install( target_arch = "powerpc64", target_arch = "riscv64" ))] - match configs.enabled_with_uuid() { - Some(uuid) => { - let meta = get_static_config_meta()?; - state.static_configs = Some(meta); - crate::grubconfigs::install( - sysroot, - Some(&source_root_dir), - installed_efi_vendor.as_deref(), - uuid, - )?; + if bootloader == Bootloader::Grub { + match configs.enabled_with_uuid() { + Some(uuid) => { + let meta = get_static_config_meta()?; + state.static_configs = Some(meta); + crate::grubconfigs::install( + sysroot, + Some(&source_root_dir), + installed_efi_vendor.as_deref(), + uuid, + )?; + } // On other architectures, assume that there's nothing to do. + None => {} } - None => {} } // Unmount the ESP, etc. drop(target_components); - let mut state_guard = - SavedState::unlocked(sysroot.try_clone()?).context("failed to acquire write lock")?; + let mut state_guard = SavedState::unlocked(opts.dest_root.clone().into(), sysroot.try_clone()?) + .context("failed to acquire write lock")?; state_guard - .update_state(&state) + .update_state(&state, bootloader) .context("failed to update state")?; Ok(()) @@ -219,6 +307,9 @@ fn get_static_config_meta() -> Result { timestamp: self_bin_meta.modified()?.into(), version: crate_version!().into(), versions: None, + + #[cfg(efi_arch)] + default_bootloader: None, }; Ok(self_meta) } @@ -273,8 +364,10 @@ pub(crate) fn get_components() -> Components { pub(crate) fn generate_update_metadata(sysroot_path: &str) -> Result<()> { // create bootupd update dir which will save component metadata files for both components let updates_dir = Path::new(sysroot_path).join(crate::model::BOOTUPD_UPDATES_DIR); + std::fs::create_dir_all(&updates_dir) .with_context(|| format!("Failed to create updates dir {:?}", &updates_dir))?; + for component in get_components().values() { if let Some(v) = component.generate_update_metadata(sysroot_path)? { println!( @@ -311,7 +404,9 @@ fn ensure_writable_boot() -> Result<()> { /// daemon implementation of component update pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result { - let mut state = SavedState::load_from_disk("/")?.unwrap_or_default(); + let bootloader = get_bootloader()?; + + let mut state = SavedState::load_from_disk("/", Some(bootloader))?.unwrap_or_default(); let component = component::new_from_name(name)?; let inst = if let Some(inst) = state.installed.get(name) { inst.clone() @@ -319,7 +414,7 @@ pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result match inst.meta.can_upgrade_to(p) { std::cmp::Ordering::Less => p, // current < available -> upgrade @@ -340,20 +435,22 @@ pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result Result Result> { + let bootloader = get_bootloader()?; let sysroot = &rootcxt.sysroot; - let mut state = SavedState::load_from_disk("/")?.unwrap_or_default(); + let mut state = SavedState::load_from_disk("/", Some(bootloader))?.unwrap_or_default(); let component = component::new_from_name(name)?; if state.installed.contains_key(name) { anyhow::bail!("Component {} is already installed", name); @@ -392,19 +490,21 @@ pub(crate) fn adopt_and_update( target_arch = "riscv64" ))] { - let grub2dir = &sysroot - .open_dir(format!("boot/{GRUB2DIR}")) - .context("Opening /boot/grub2")?; - ensure_grub_permissions(grub2dir)?; + if bootloader == Bootloader::Grub { + let grub2dir = &sysroot + .open_dir(format!("boot/{GRUB2DIR}")) + .context("Opening /boot/grub2")?; + ensure_grub_permissions(grub2dir)?; + } } - let Some(update) = component.query_update(sysroot)? else { + let Some(update) = component.query_update(sysroot, bootloader)? else { anyhow::bail!("Component {} has no available update", name); }; let sysroot = sysroot.try_clone()?; - let mut state_guard = - SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?; + let mut state_guard = SavedState::acquire_write_lock(rootcxt.path.clone(), sysroot) + .context("Failed to acquire write lock")?; let inst = component .adopt_update(&rootcxt, &update, with_static_config) @@ -420,7 +520,7 @@ pub(crate) fn adopt_and_update( println!("Static GRUB configuration has been adopted successfully."); } - state_guard.update_state(&state)?; + state_guard.update_state(&state, bootloader)?; return Ok(Some(update)); } else { // Nothing adopted, skip @@ -433,7 +533,7 @@ pub(crate) fn adopt_and_update( /// then falling back to `/sysroot`. This avoids issues with virtual /// filesystems like composefs that are mounted on `/`. #[context("Finding block device from boot or sysroot")] -fn list_dev_current_root() -> Result { +pub(crate) fn list_dev_current_root() -> Result { let auth = cap_std::ambient_authority(); for path in ["/boot", "/sysroot"] { if let Ok(dir) = Dir::open_ambient_dir(path, auth) { @@ -447,7 +547,7 @@ fn list_dev_current_root() -> Result { /// daemon implementation of component validate pub(crate) fn validate(name: &str) -> Result { - let state = SavedState::load_from_disk("/")?.unwrap_or_default(); + let state = SavedState::load_from_disk("/", Some(get_bootloader()?))?.unwrap_or_default(); let component = component::new_from_name(name)?; let Some(inst) = state.installed.get(name) else { anyhow::bail!("Component {} is not installed", name); @@ -456,11 +556,16 @@ pub(crate) fn validate(name: &str) -> Result { component.validate(inst, &device) } +/// Impl for bootupctl status +/// This function assumes we're not running in a container pub(crate) fn status() -> Result { let mut ret: Status = Default::default(); let mut known_components = get_components(); - let sysroot = Dir::open_ambient_dir("/", ambient_authority())?; - let state = SavedState::load_from_disk("/")?; + let root = Dir::open_ambient_dir("/", ambient_authority())?; + + let bootloader = get_bootloader()?; + let state = SavedState::load_from_disk("/", Some(bootloader))?; + if let Some(state) = state { for (name, ic) in state.installed.iter() { log::trace!("Gathering status for installed component: {}", name); @@ -469,7 +574,7 @@ pub(crate) fn status() -> Result { .ok_or_else(|| anyhow!("Unknown component installed: {}", name))?; let component = component.as_ref(); let interrupted = state.pending.as_ref().and_then(|p| p.get(name.as_str())); - let update = component.query_update(&sysroot)?; + let update = component.query_update(&root, bootloader)?; let updatable = ComponentUpdatable::from_metadata(&ic.meta, update.as_ref()); let adopted_from = ic.adopted_from.clone(); ret.components.insert( @@ -508,7 +613,7 @@ pub(crate) fn status() -> Result { if let Some(adopt_ver) = component::query_adopt_state()? { let component = component::new_from_name(&name)?; // Skip if the update metadata could not be found - if component.query_update(&sysroot)?.is_none() { + if component.query_update(&root, bootloader)?.is_none() { continue; }; ret.adoptable.insert(name.to_string(), adopt_ver); diff --git a/src/cli/bootupctl.rs b/src/cli/bootupctl.rs index 1c30b24e..38bb9a54 100644 --- a/src/cli/bootupctl.rs +++ b/src/cli/bootupctl.rs @@ -73,6 +73,9 @@ pub enum CtlBackend { Generate(super::bootupd::GenerateOpts), #[clap(name = "install", hide = true)] Install(super::bootupd::InstallOpts), + #[clap(hide = true)] + #[cfg(efi_arch)] + SetDefaultBootloader(super::bootupd::DefaultBootloaderOpts), } #[derive(Debug, Parser)] @@ -109,6 +112,10 @@ impl CtlCommand { CtlVerb::Backend(CtlBackend::Install(opts)) => { super::bootupd::DCommand::run_install(opts) } + #[cfg(efi_arch)] + CtlVerb::Backend(CtlBackend::SetDefaultBootloader(opts)) => { + super::bootupd::DCommand::set_default_bootloader(opts) + } CtlVerb::MigrateStaticGrubConfig => Self::run_migrate_static_grub_config(), } } diff --git a/src/cli/bootupd.rs b/src/cli/bootupd.rs index b560910f..a9b8951b 100644 --- a/src/cli/bootupd.rs +++ b/src/cli/bootupd.rs @@ -1,3 +1,4 @@ +use crate::bootloader::Bootloader; use crate::bootupd::{self, ConfigMode}; use anyhow::{Context, Result}; use camino::Utf8Path; @@ -39,50 +40,56 @@ pub enum DVerb { GenerateUpdateMetadata(GenerateOpts), #[clap(name = "install", about = "Install components")] Install(InstallOpts), + #[cfg(efi_arch)] + SetDefaultBootloader(DefaultBootloaderOpts), } #[derive(Debug, Parser)] -pub struct InstallOpts { +pub(crate) struct InstallOpts { /// Source root #[clap(long, value_parser, default_value_t = String::from("/"))] - src_root: String, + pub(crate) src_root: String, /// Target root #[clap(value_parser)] - dest_root: String, + pub(crate) dest_root: String, /// Target device(s) for bootloader installation. Can be specified multiple /// times to install to multiple devices (e.g., for multi-disk RAID/LVM setups). #[clap(long, action = clap::ArgAction::Append, conflicts_with = "filesystem")] - device: Vec, + pub(crate) device: Vec, /// Filesystem path to inspect for backing devices. Bootupd will walk up the /// device hierarchy to find physical disks and install to all ESPs found. #[clap(long)] - filesystem: Option, + pub(crate) filesystem: Option, /// Enable installation of the built-in static config files #[clap(long)] - with_static_configs: bool, + pub(crate) with_static_configs: bool, /// Implies `--with-static-configs`. When present, this also writes a /// file with the UUID of the target filesystems. #[clap(long)] - write_uuid: bool, + pub(crate) write_uuid: bool, /// On EFI systems, invoke `efibootmgr` to update the firmware. #[clap(long)] - update_firmware: bool, + pub(crate) update_firmware: bool, #[clap(long = "component", conflicts_with = "auto")] /// Only install these components - components: Option>, + pub(crate) components: Option>, /// Automatically choose components based on booted host state. /// /// For example on x86_64, if the host system is booted via EFI, /// then only enable installation to the ESP. #[clap(long)] - auto: bool, + pub(crate) auto: bool, + + /// The bootloader to use + #[clap(long)] + pub(crate) bootloader: Option, } #[derive(Debug, Parser)] @@ -92,12 +99,23 @@ pub struct GenerateOpts { sysroot: Option, } +#[derive(Debug, Parser)] +pub struct DefaultBootloaderOpts { + /// Physical root mountpoint + #[clap(long)] + pub(crate) sysroot: Option, + /// The bootloader to be set as the default + pub(crate) bootloader: Bootloader, +} + impl DCommand { /// Run CLI application. pub fn run(self) -> Result<()> { match self.cmd { DVerb::Install(opts) => Self::run_install(opts), DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts), + #[cfg(efi_arch)] + DVerb::SetDefaultBootloader(opts) => Self::set_default_bootloader(opts), } } @@ -135,16 +153,29 @@ impl DCommand { .collect::>>()? }; - bootupd::install( - &opts.src_root, - &opts.dest_root, - &devices, - configmode, - opts.update_firmware, - opts.components.as_deref(), - opts.auto, - ) - .context("boot data installation failed")?; + bootupd::install(&opts, &devices, configmode).context("boot data installation failed")?; + Ok(()) + } + + #[cfg(efi_arch)] + pub(crate) fn set_default_bootloader(opts: DefaultBootloaderOpts) -> Result<()> { + let all_components = crate::bootupd::get_components(); + let target_components: Vec<_> = all_components.values().collect(); + + for &component in target_components.iter() { + if !component.is_bootloader_supported(opts.bootloader) { + log::info!( + "{} is not supported for {}. Skipping...", + opts.bootloader, + component.name() + ); + + continue; + } + + component.set_default_bootloader(&opts)?; + } + Ok(()) } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 285e1ea5..02212b56 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,7 +4,7 @@ use anyhow::Result; use clap::Parser; use log::LevelFilter; mod bootupctl; -mod bootupd; +pub(crate) mod bootupd; /// Top-level multicall CLI. #[derive(Debug, Parser)] diff --git a/src/component.rs b/src/component.rs index 403ffeec..094d5172 100644 --- a/src/component.rs +++ b/src/component.rs @@ -14,7 +14,9 @@ use std::path::{Path, PathBuf}; use bootc_internal_blockdev::Device; -use crate::{bootupd::RootContext, model::*}; +#[cfg(efi_arch)] +use crate::cli::bootupd::DefaultBootloaderOpts; +use crate::{bootloader::Bootloader, bootupd::RootContext, model::*}; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "kebab-case")] @@ -24,6 +26,27 @@ pub(crate) enum ValidationResult { Errors(Vec), } +#[derive(PartialEq, Eq, Clone, Copy)] +pub(crate) enum ComponentType { + Bios, + Efi, +} + +impl From for &'static str { + fn from(val: ComponentType) -> Self { + match val { + ComponentType::Bios => "BIOS", + ComponentType::Efi => "EFI", + } + } +} + +impl std::fmt::Display for ComponentType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str((*self).into()) + } +} + /// A bootloader subsystem (EFI or BIOS) that can be installed, updated, and validated. /// /// Components encapsulate platform-specific bootloader management. Each implementation @@ -34,6 +57,10 @@ pub(crate) trait Component { /// and should remain stable. fn name(&self) -> &'static str; + /// Returns the type of the component as an enum + /// Prefer this over [`Component::name`] + fn component_type(&self) -> ComponentType; + /// In an operating system whose initially booted disk image is not /// using bootupd, detect whether it looks like the component exists /// and "synthesize" content metadata from it. @@ -63,6 +90,7 @@ pub(crate) trait Component { dest_root: &str, device: Option<&Device>, update_firmware: bool, + bootloader: Bootloader, ) -> Result; /// Implementation of `bootupd generate-update-metadata` for a given component. @@ -73,7 +101,11 @@ pub(crate) trait Component { fn generate_update_metadata(&self, sysroot: &str) -> Result>; /// Used on the client to query for an update cached in the current booted OS. - fn query_update(&self, sysroot: &Dir) -> Result>; + fn query_update( + &self, + sysroot: &Dir, + bootloader: Bootloader, + ) -> Result>; /// This is called in the update code if query_update() returned no metadata. /// It should return an error if the current booted system should expect some @@ -92,6 +124,84 @@ pub(crate) trait Component { /// Locating efi vendor dir fn get_efi_vendor(&self, sysroot: &Path) -> Result>; + + fn is_bootloader_supported(&self, bootloader: Bootloader) -> bool; + + /// Given a component, return metadata on the available update (if any) + // + /// If bootloader is Some, all metadata not pertaining to the specified bootloader + /// is filtered + /// + /// If bootloader is None, no filtering is performed + #[context("Loading update for component {}", self.name())] + fn get_component_update( + &self, + sysroot: &Dir, + bootloader: Option, + ) -> Result> { + let name = self.component_update_data_name(); + let path = Path::new(BOOTUPD_UPDATES_DIR).join(&name); + + let Some(f) = sysroot.open_optional(&path)? else { + return Ok(None); + }; + + let mut f = std::io::BufReader::new(f); + let mut u = serde_json::from_reader(&mut f) + .with_context(|| format!("failed to parse {:?}", &path))?; + + let Some(bootloader) = bootloader else { + return Ok(Some(u)); + }; + + // We store metadata of all bootloaders present in the image + // So here, we will now filter out the bootloaders + u.filter_bootloader(bootloader); + + Ok(Some(u)) + } + + /// Returns the name of the JSON file containing a component's available update metadata installed + /// into the booted operating system root. + fn component_update_data_name(&self) -> PathBuf { + Path::new(&format!("{}.json", self.name())).into() + } + + #[cfg(efi_arch)] + fn set_default_bootloader(&self, opts: &DefaultBootloaderOpts) -> Result<()> { + if !self.is_bootloader_supported(opts.bootloader) { + anyhow::bail!("{} not supported for {}", opts.bootloader, self.name()); + } + + let root_path = opts.sysroot.as_deref().unwrap_or("/"); + + let root = Dir::open_ambient_dir(root_path, ambient_authority()) + .with_context(|| format!("Opening {root_path}"))?; + + // This command expects bootupd.json to be present + let mut update_meta = self + .get_component_update(&root, None)? + .ok_or_else(|| anyhow::anyhow!("Expected to get update metadata"))?; + + if !update_meta.is_bootloader_available(opts.bootloader) { + anyhow::bail!("{} is not present in metadata", opts.bootloader); + } + + update_meta.default_bootloader = Some(opts.bootloader); + + write_update_metadata(root_path, self.component_update_data_name(), &update_meta)?; + + Ok(()) + } + + #[cfg(efi_arch)] + fn get_default_bootloader(&self, root: &Dir) -> Result> { + let update_meta = self + .get_component_update(&root, None)? + .ok_or_else(|| anyhow::anyhow!("Expected to get update metadata"))?; + + Ok(update_meta.default_bootloader) + } } /// Given a component name, create an implementation. @@ -134,24 +244,17 @@ pub(crate) fn component_updatedir(sysroot: &str, component: &dyn Component) -> P Path::new(sysroot).join(component_updatedirname(component)) } -/// Returns the name of the JSON file containing a component's available update metadata installed -/// into the booted operating system root. -fn component_update_data_name(component: &dyn Component) -> PathBuf { - Path::new(&format!("{}.json", component.name())).into() -} - /// Helper method for writing an update file pub(crate) fn write_update_metadata( sysroot: &str, - component: &dyn Component, + file_path: PathBuf, meta: &ContentMetadata, ) -> Result<()> { let sysroot = Dir::open_ambient_dir(sysroot, ambient_authority())?; let dir = sysroot.open_dir(BOOTUPD_UPDATES_DIR)?; - let name = component_update_data_name(component); dir.atomic_write_with_perms( - name, + file_path, serde_json::to_vec(&meta).context("Serializing metadata")?, Permissions::from_mode(0o644), )?; @@ -159,24 +262,6 @@ pub(crate) fn write_update_metadata( Ok(()) } -/// Given a component, return metadata on the available update (if any) -#[context("Loading update for component {}", component.name())] -pub(crate) fn get_component_update( - sysroot: &Dir, - component: &dyn Component, -) -> Result> { - let name = component_update_data_name(component); - let path = Path::new(BOOTUPD_UPDATES_DIR).join(name); - if let Some(f) = sysroot.open_optional(&path)? { - let mut f = std::io::BufReader::new(f); - let u = serde_json::from_reader(&mut f) - .with_context(|| format!("failed to parse {:?}", &path))?; - Ok(Some(u)) - } else { - Ok(None) - } -} - #[context("Querying adoptable state")] pub(crate) fn query_adopt_state() -> Result> { // This would be extended with support for other operating systems later @@ -185,6 +270,8 @@ pub(crate) fn query_adopt_state() -> Result> { timestamp: coreos_aleph.ts, version: coreos_aleph.aleph.version, versions: None, + #[cfg(efi_arch)] + default_bootloader: None, }; log::trace!("Adoptable: {:?}", &meta); return Ok(Some(Adoptable { @@ -202,6 +289,8 @@ pub(crate) fn query_adopt_state() -> Result> { timestamp, version: "unknown".to_string(), versions: None, + #[cfg(efi_arch)] + default_bootloader: None, }; return Ok(Some(Adoptable { version: meta, @@ -249,10 +338,10 @@ mod tests { let all_components = crate::bootupd::get_components(); let target_components: Vec<_> = all_components.values().collect(); for &component in target_components.iter() { - if component.name() == "BIOS" { + if component.component_type() == ComponentType::Bios { assert_eq!(component.get_efi_vendor(tdp)?, None); } - if component.name() == "EFI" { + if component.component_type() == ComponentType::Efi { let x = component.get_efi_vendor(tdp); assert_eq!(x.is_err(), true); efi.remove_dir_all("centos")?; @@ -291,4 +380,80 @@ mod tests { } Ok(()) } + + #[test] + #[cfg(efi_arch)] + fn test_set_default_bootloader() -> Result<()> { + use chrono::Utc; + + let td = tempfile::tempdir()?; + let sysroot = td.path().to_str().unwrap().to_string(); + let tdir = Dir::open_ambient_dir(&sysroot, ambient_authority())?; + + // Create the updates directory + let mut dir_builder = DirBuilder::new(); + dir_builder.mode(0o755); + dir_builder.recursive(true); + tdir.create_dir_with(BOOTUPD_UPDATES_DIR, &dir_builder)?; + + // Create test metadata without the target bootloader + let meta = ContentMetadata { + timestamp: Utc::now(), + version: "grub2-efi-x64-1:2.12-21.fc41.x86_64".into(), // Only has grub2-efi, not grub-cc + versions: None, + default_bootloader: None, + }; + + let all_components = crate::bootupd::get_components(); + let efi_component = all_components.get("EFI").unwrap(); + + // Write metadata file + write_update_metadata(&sysroot, efi_component.component_update_data_name(), &meta)?; + + let opts = DefaultBootloaderOpts { + sysroot: Some(sysroot.clone()), + bootloader: Bootloader::GrubCC, // This bootloader is not in the metadata version string + }; + + if efi_component.is_bootloader_supported(opts.bootloader) { + let result = efi_component.set_default_bootloader(&opts); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("is not present in metadata")); + } + + // Now create test metadata with both bootloaders available + let meta = ContentMetadata { + timestamp: Utc::now(), + version: "grub2-efi-x64-1:2.12-21.fc41.x86_64,grub-cc-efi-x64-1:2.12-21.fc41.x86_64" + .into(), + versions: None, + default_bootloader: None, + }; + + // Write initial metadata file + write_update_metadata(&sysroot, efi_component.component_update_data_name(), &meta)?; + + let opts = DefaultBootloaderOpts { + sysroot: Some(sysroot.clone()), + bootloader: Bootloader::GrubCC, + }; + + if efi_component.is_bootloader_supported(opts.bootloader) { + // Should succeed + let result = efi_component.set_default_bootloader(&opts); + assert!(result.is_ok()); + + // Verify the metadata was updated + let root = Dir::open_ambient_dir(&sysroot, ambient_authority())?; + let updated_meta = efi_component.get_component_update(&root, None)?; + assert!(updated_meta.is_some()); + let updated_meta = updated_meta.unwrap(); + assert_eq!(updated_meta.default_bootloader, Some(Bootloader::GrubCC)); + } + + Ok(()) + } } diff --git a/src/efi.rs b/src/efi.rs index cb5c1b0c..fceecd98 100644 --- a/src/efi.rs +++ b/src/efi.rs @@ -24,6 +24,7 @@ use widestring::U16CString; use bootc_internal_blockdev::Device; +use crate::bootloader::{get_bootloader, Bootloader}; use crate::bootupd::RootContext; use crate::freezethaw::fsfreeze_thaw_cycle; use crate::model::*; @@ -86,9 +87,12 @@ pub(crate) fn is_efi_booted() -> Result { .map_err(Into::into) } -#[derive(Default)] +#[derive(Default, Debug)] pub(crate) struct Efi { mountpoint: RefCell>, + /// Whether the above mountpoint was already mounted or not + /// Won't unmount if it was already mounted + was_mounted: RefCell, } impl Efi { @@ -115,6 +119,7 @@ impl Efi { continue; } util::ensure_writable_mount(&path)?; + *self.was_mounted.borrow_mut() = true; found_mount = Some(path); break; } @@ -147,6 +152,7 @@ impl Efi { if is_mount_point(&mnt)? { log::debug!("ESP already mounted at {mnt:?}, reusing"); mountpoint = Some(mnt); + *self.was_mounted.borrow_mut() = true; break; } } @@ -178,7 +184,8 @@ impl Efi { Ok(destdir) } - fn unmount(&self) -> Result<()> { + pub(crate) fn unmount(&self) -> Result<()> { + *self.was_mounted.borrow_mut() = false; if let Some(mount) = self.mountpoint.borrow_mut().take() { Command::new("umount") .arg(&mount) @@ -286,7 +293,7 @@ fn read_efi_var_utf16_string(name: &str) -> Option { } /// Read the LoaderInfo EFI variable if it exists. -fn get_loader_info() -> Option { +pub(crate) fn get_loader_info() -> Option { read_efi_var_utf16_string(LOADER_INFO_VAR_STR) } @@ -312,7 +319,22 @@ fn skip_systemd_bootloaders() -> bool { impl Component for Efi { fn name(&self) -> &'static str { - "EFI" + self.component_type().into() + } + + fn component_type(&self) -> ComponentType { + ComponentType::Efi + } + + fn is_bootloader_supported(&self, bootloader: Bootloader) -> bool { + #[cfg(not(efi_arch))] + return matches!(bootloader, Bootloader::Grub); + + #[cfg(efi_arch)] + matches!( + bootloader, + Bootloader::Grub | Bootloader::GrubCC | Bootloader::Systemd + ) } fn query_adopt(&self, devices: &Option>) -> Result> { @@ -375,7 +397,8 @@ impl Component for Efi { let updated_path = { let efilib_path = rootcxt.path.join(EFILIB); - if efilib_path.exists() && get_efi_component_from_usr(&rootcxt.path, EFILIB)?.is_some() + if efilib_path.exists() + && get_efi_component_from_usr(&rootcxt.path, EFILIB, None)?.is_some() { PathBuf::from(EFILIB) } else { @@ -386,7 +409,9 @@ impl Component for Efi { .sysroot .open_dir(&updated_path) .with_context(|| format!("opening update dir {}", updated_path.display()))?; - let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; + // TODO(Johan-Liebert1): Handle adpot + let updatef = + filetree::FileTree::new_from_dir(&updated, None).context("reading update dir")?; let esp_devices = esp_devices.unwrap_or_default(); for esp in esp_devices { @@ -434,10 +459,11 @@ impl Component for Efi { dest_root: &str, device: Option<&Device>, update_firmware: bool, + bootloader: Bootloader, ) -> Result { let src_dir = Dir::open_ambient_dir(src_root, ambient_authority()) .with_context(|| format!("opening source directory {src_root}"))?; - let Some(meta) = get_component_update(&src_dir, self)? else { + let Some(meta) = self.get_component_update(&src_dir, Some(bootloader))? else { anyhow::bail!("No update metadata for component {} found", self.name()); }; log::debug!("Found metadata {}", meta.version); @@ -468,7 +494,7 @@ impl Component for Efi { let src_path = Utf8Path::new(src_root); let efi_comps = if src_path.join(EFILIB).exists() { - get_efi_component_from_usr(&src_path, EFILIB)? + get_efi_component_from_usr(src_path, EFILIB, Some(bootloader))? } else { None }; @@ -493,8 +519,21 @@ impl Component for Efi { &src.to_owned() }; + // Now that we have multiple bootloaders (all named grubx64.efi/grubaa64.efi + // due to that name being baked into the shim), we can't blindly use the key + // "fedora/grubx64.efi" to refer to the installed bootloader, hence we ask for + // any extra bootloaders to be not included in the FileTree + let dirs_to_skip = Bootloader::iter() + .filter(|b| *b != bootloader) + .map(|b| b.efi_component_name()) + .collect::>(); + // Get filetree from efi path - let ft = crate::filetree::FileTree::new_from_dir(&src_dir.open_dir(efi_path)?)?; + let ft = crate::filetree::FileTree::new_from_dir( + &src_dir.open_dir(efi_path)?, + Some(dirs_to_skip), + )?; + if update_firmware { if let Some(dev) = device { if let Some(vendordir) = @@ -516,15 +555,20 @@ impl Component for Efi { rootcxt: &RootContext, current: &InstalledContent, ) -> Result { + let bootloader = get_bootloader()?; + let currentf = current .filetree .as_ref() .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; let sysroot_dir = &rootcxt.sysroot; - let updatemeta = self.query_update(sysroot_dir)?.expect("update available"); + let updatemeta = self + .query_update(sysroot_dir, bootloader)? + .expect("update available"); let updated_path = { let efilib_path = rootcxt.path.join(EFILIB); - if efilib_path.exists() && get_efi_component_from_usr(&rootcxt.path, EFILIB)?.is_some() + if efilib_path.exists() + && get_efi_component_from_usr(&rootcxt.path, EFILIB, None)?.is_some() { PathBuf::from(EFILIB) } else { @@ -536,7 +580,14 @@ impl Component for Efi { .sysroot .open_dir(&updated_path) .with_context(|| format!("opening update dir {}", updated_path.display()))?; - let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; + + let dirs_to_skip = Bootloader::iter() + .filter(|b| *b != bootloader) + .map(|b| b.efi_component_name()) + .collect::>(); + + let updatef = filetree::FileTree::new_from_dir(&updated, Some(dirs_to_skip)) + .context("reading update dir")?; let diff = currentf.diff(&updatef)?; let Some(esp_devices) = rootcxt.device.find_colocated_esps()? else { @@ -608,13 +659,21 @@ impl Component for Efi { ostreeboot.remove_all_optional("efi/EFI")?; }; - write_update_metadata(sysroot_path.as_str(), self, &metadata)?; + write_update_metadata( + sysroot_path.as_str(), + self.component_update_data_name(), + &metadata, + )?; Ok(Some(metadata)) } - fn query_update(&self, sysroot: &Dir) -> Result> { - get_component_update(sysroot, self) + fn query_update( + &self, + sysroot: &Dir, + bootloader: Bootloader, + ) -> Result> { + self.get_component_update(sysroot, Some(bootloader)) } fn query_requires_update(&self, _sysroot: &Dir) -> Result<()> { @@ -692,7 +751,12 @@ impl Component for Efi { impl Drop for Efi { fn drop(&mut self) { - log::debug!("Unmounting"); + if *self.was_mounted.borrow() { + log::debug!("mountpoint was already mounted. Won't unmount",); + return; + } + + log::debug!("Unmounting {:?}", self.mountpoint); let _ = self.unmount(); } } @@ -805,7 +869,10 @@ fn find_file_recursive>(dir: P, target_file: &str) -> Result Result { - let Some(efi_components) = get_efi_component_from_usr(sysroot_path, EFILIB)? else { + // We DO NOT want to filter while generating metadata + // We want to have metadata for multiple bootloaders + // Later on, while installing, we'll filter out the stuff we don't need + let Some(efi_components) = get_efi_component_from_usr(sysroot_path, EFILIB, None)? else { anyhow::bail!("Failed to find EFI components"); }; @@ -824,6 +891,8 @@ fn generate_meta_from_usr_efi(sysroot_path: &Utf8Path) -> Result//EFI" +/// Filter the components by Bootloader, if Bootloader is None, no filtering is performed fn get_efi_component_from_usr<'a>( sysroot: &'a Utf8Path, usr_path: &'a str, + bootloader: Option, ) -> Result>> { let efilib_path = sysroot.join(usr_path); let skip_count = Utf8Path::new(usr_path).components().count(); @@ -876,7 +947,22 @@ fn get_efi_component_from_usr<'a>( } components.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Some(components)) + let Some(bootloader) = bootloader else { + return Ok(Some(components)); + }; + + // Remove all EFI Components not associated with the bootloader + let to_remove = Bootloader::iter() + .filter(|b| *b != bootloader) + .map(|b| b.efi_component_name()) + .collect::>(); + + let efi_comps = components + .into_iter() + .filter(|comp| !to_remove.contains(&comp.name.as_str())) + .collect::>(); + + Ok(Some(efi_comps)) } /// Copies usr/lib/ostree-boot/EFI to usr/lib/bootupd/updates @@ -1040,34 +1126,97 @@ Boot0003* test"; } #[test] + #[cfg(efi_arch)] fn test_get_efi_component_from_usr() -> Result<()> { let tmpdir: &tempfile::TempDir = &tempfile::tempdir()?; let tpath = tmpdir.path(); let efi_path = tpath.join("usr/lib/efi"); - std::fs::create_dir_all(efi_path.join("BAR/1.1/EFI"))?; - std::fs::create_dir_all(efi_path.join("FOO/1.1/EFI"))?; - std::fs::create_dir_all(efi_path.join("FOOBAR/1.1/test"))?; + + // Create realistic directory structure for both bootloaders + // grub-cc structure + std::fs::create_dir_all(efi_path.join("grub-cc/1:2.12-59.fc45/EFI/fedora"))?; + std::fs::File::create(efi_path.join("grub-cc/1:2.12-59.fc45/EFI/fedora/grubx64-cc.efi"))?; + + // grub2 structure + std::fs::create_dir_all(efi_path.join("grub2/1:2.12-58.fc44/EFI/fedora"))?; + std::fs::File::create(efi_path.join("grub2/1:2.12-58.fc44/EFI/fedora/grubx64.efi"))?; + + // shim structure + std::fs::create_dir_all(efi_path.join("shim/16.1-5/EFI/BOOT"))?; + std::fs::create_dir_all(efi_path.join("shim/16.1-5/EFI/fedora"))?; + std::fs::File::create(efi_path.join("shim/16.1-5/EFI/BOOT/BOOTX64.EFI"))?; + std::fs::File::create(efi_path.join("shim/16.1-5/EFI/BOOT/fbx64.efi"))?; + std::fs::File::create(efi_path.join("shim/16.1-5/EFI/fedora/BOOTX64.CSV"))?; + std::fs::File::create(efi_path.join("shim/16.1-5/EFI/fedora/mmx64.efi"))?; + std::fs::File::create(efi_path.join("shim/16.1-5/EFI/fedora/shim.efi"))?; + std::fs::File::create(efi_path.join("shim/16.1-5/EFI/fedora/shimx64.efi"))?; + let utf8_tpath = Utf8Path::from_path(tpath).ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?; - let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?; + + // Test with no filtering - should return all EFI components + let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB, None)?; assert_eq!( efi_comps, Some(vec![ EFIComponent { - name: "BAR".to_string(), - version: "1.1".to_string(), - path: Utf8PathBuf::from("usr/lib/efi/BAR/1.1/EFI"), + name: "grub-cc".to_string(), + version: "1:2.12-59.fc45".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/grub-cc/1:2.12-59.fc45/EFI"), }, EFIComponent { - name: "FOO".to_string(), - version: "1.1".to_string(), - path: Utf8PathBuf::from("usr/lib/efi/FOO/1.1/EFI"), + name: "grub2".to_string(), + version: "1:2.12-58.fc44".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/grub2/1:2.12-58.fc44/EFI"), + }, + EFIComponent { + name: "shim".to_string(), + version: "16.1-5".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/shim/16.1-5/EFI"), + }, + ]) + ); + + // Test with filtering for Grub - should only return grub2 and shim + let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB, Some(Bootloader::Grub))?; + assert_eq!( + efi_comps, + Some(vec![ + EFIComponent { + name: "grub2".to_string(), + version: "1:2.12-58.fc44".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/grub2/1:2.12-58.fc44/EFI"), + }, + EFIComponent { + name: "shim".to_string(), + version: "16.1-5".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/shim/16.1-5/EFI"), }, ]) ); - std::fs::remove_dir_all(efi_path.join("BAR/1.1/EFI"))?; - std::fs::remove_dir_all(efi_path.join("FOO/1.1/EFI"))?; - let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?; + + // Test with filtering for GrubCC - should only return grub-cc and shim + let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB, Some(Bootloader::GrubCC))?; + assert_eq!( + efi_comps, + Some(vec![ + EFIComponent { + name: "grub-cc".to_string(), + version: "1:2.12-59.fc45".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/grub-cc/1:2.12-59.fc45/EFI"), + }, + EFIComponent { + name: "shim".to_string(), + version: "16.1-5".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/shim/16.1-5/EFI"), + }, + ]) + ); + + // Test with empty directory - should return None + std::fs::remove_dir_all(&efi_path)?; + std::fs::create_dir_all(&efi_path)?; + let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB, None)?; assert_eq!(efi_comps, None); Ok(()) } diff --git a/src/filetree.rs b/src/filetree.rs index 31d0916d..a640070d 100644 --- a/src/filetree.rs +++ b/src/filetree.rs @@ -191,12 +191,20 @@ impl FileTree { target_arch = "aarch64", target_arch = "riscv64" ))] - pub(crate) fn new_from_dir(dir: &Dir) -> Result { + pub(crate) fn new_from_dir(dir: &Dir, dirs_to_skip: Option>) -> Result { let mut children = BTreeMap::new(); + let dirs_to_skip = dirs_to_skip.unwrap_or([].into()); + for (k, mut v) in Self::unsorted_from_dir(dir)?.drain() { - let k_path = get_dest_efi_path(Utf8Path::new(&k)).to_string(); + let src = Utf8Path::new(&k); + let k_path = get_dest_efi_path(src); + + if src.components().any(|c| dirs_to_skip.contains(&c.as_str())) { + continue; + } + v.source = Some(k); - children.insert(k_path, v); + children.insert(k_path.to_string(), v); } Ok(Self { children }) @@ -532,8 +540,8 @@ mod tests { use std::path::Path; fn run_diff(a: &Dir, b: &Dir) -> Result { - let ta = FileTree::new_from_dir(a)?; - let tb = FileTree::new_from_dir(b)?; + let ta = FileTree::new_from_dir(a, None)?; + let tb = FileTree::new_from_dir(b, None)?; let diff = ta.diff(&tb)?; Ok(diff) } @@ -557,15 +565,15 @@ mod tests { let c = Dir::open_ambient_dir(&c, ambient_authority())?; let da = Dir::open_ambient_dir(a, ambient_authority())?; let db = Dir::open_ambient_dir(b, ambient_authority())?; - let ta = FileTree::new_from_dir(&da)?; - let tb = FileTree::new_from_dir(&db)?; + let ta = FileTree::new_from_dir(&da, None)?; + let tb = FileTree::new_from_dir(&db, None)?; let diff = ta.diff(&tb)?; let rdiff = tb.diff(&ta)?; assert_eq!(diff.count(), rdiff.count()); assert_eq!(diff.additions.len(), rdiff.removals.len()); assert_eq!(diff.changes.len(), rdiff.changes.len()); apply_diff(&db, &c, &diff, opts)?; - let tc = FileTree::new_from_dir(&c)?; + let tc = FileTree::new_from_dir(&c, None)?; let newdiff = tb.diff(&tc)?; let skip_removals = opts.map(|o| o.skip_removals).unwrap_or(false); if skip_removals { @@ -622,8 +630,8 @@ mod tests { let diff = run_diff(&a, &b)?; assert_eq!(diff.count(), 1); assert_eq!(diff.removals.len(), 1); - let ta = FileTree::new_from_dir(&a)?; - let tb = FileTree::new_from_dir(&b)?; + let ta = FileTree::new_from_dir(&a, None)?; + let tb = FileTree::new_from_dir(&b, None)?; let cdiff = ta.changes(&tb)?; assert_eq!(cdiff.count(), 1); assert_eq!(cdiff.removals.len(), 1); @@ -654,7 +662,7 @@ mod tests { let diff = run_diff(&a, &b)?; assert_eq!(diff.count(), 1); assert_eq!(diff.changes.len(), 1); - let ta = FileTree::new_from_dir(&a)?; + let ta = FileTree::new_from_dir(&a, None)?; let rdiff = ta.relative_diff_to(&b)?; assert_eq!(rdiff.count(), diff.count()); assert_eq!(rdiff.changes.len(), diff.changes.len()); @@ -685,8 +693,8 @@ mod tests { { let a = Dir::open_ambient_dir(&a, ambient_authority())?; let b = Dir::open_ambient_dir(&b, ambient_authority())?; - let ta = FileTree::new_from_dir(&a)?; - let tb = FileTree::new_from_dir(&b)?; + let ta = FileTree::new_from_dir(&a, None)?; + let tb = FileTree::new_from_dir(&b, None)?; let diff = ta.diff(&tb)?; assert_eq!(diff.changes.len(), 1); assert_eq!(diff.additions.len(), 1); @@ -716,8 +724,8 @@ mod tests { fs::write(c.join(bar).join("newfile"), "filedata")?; let a = Dir::open_ambient_dir(&a.join("EFI"), ambient_authority())?; let c = Dir::open_ambient_dir(&c, ambient_authority())?; - let ta = FileTree::new_from_dir(&a)?; - let tc = FileTree::new_from_dir(&c)?; + let ta = FileTree::new_from_dir(&a, None)?; + let tc = FileTree::new_from_dir(&c, None)?; let diff = ta.diff(&tc)?; assert_eq!(diff.changes.len(), 1); assert_eq!(diff.additions.len(), 1); @@ -895,7 +903,7 @@ mod tests { } { b.remove_file(testfile)?; - let ta = FileTree::new_from_dir(&a)?; + let ta = FileTree::new_from_dir(&a, None)?; let diff = ta.relative_diff_to(&b)?; assert_eq!(diff.count(), 1); assert_eq!(diff.removals.len(), 1); diff --git a/src/main.rs b/src/main.rs index 56e9ef99..53371753 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ Refs: mod backend; #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] mod bios; +mod bootloader; mod bootupd; mod cli; mod component; diff --git a/src/model.rs b/src/model.rs index e899a015..9ad02967 100644 --- a/src/model.rs +++ b/src/model.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::collections::BTreeMap; +use crate::bootloader::Bootloader; use crate::packagesystem::*; /// The directory where updates are stored @@ -23,6 +24,10 @@ pub(crate) struct ContentMetadata { pub(crate) version: String, /// Transfer version into Module struct list pub(crate) versions: Option>, + /// The default bootloader to install if at install time no bootloader option is + /// provided + #[cfg(efi_arch)] + pub(crate) default_bootloader: Option, } impl ContentMetadata { @@ -33,6 +38,51 @@ impl ContentMetadata { compare_package_versions(&self.version, &target.version) } } + + /// Returns bootloaders are available for install + #[cfg(efi_arch)] + pub(crate) fn available_bootloaders(&self) -> Vec { + let mut available = vec![]; + + if let Some(versions) = &self.versions { + for version in versions { + if let Ok(b) = Bootloader::try_from_efi_component_name(&version.name) { + available.push(b); + } + } + } + + return available; + } + + #[cfg(efi_arch)] + pub(crate) fn is_bootloader_available(&mut self, bootloader: Bootloader) -> bool { + self.version + .split(",") + .any(|v| v.starts_with(bootloader.efi_component_name())) + } + + pub(crate) fn filter_bootloader(&mut self, bootloader: Bootloader) { + let to_remove = Bootloader::iter() + .filter(|b| *b != bootloader) + .map(|b| b.efi_component_name()) + .collect::>(); + + // Version is of type "-,-" + self.version = self + .version + .split(",") + .filter(|v| { + // Keep everything that is NOT in to_remove + !to_remove.iter().any(|b| v.starts_with(b)) + }) + .collect::>() + .join(","); + + if let Some(versions) = &mut self.versions { + versions.retain(|v| !to_remove.contains(&v.name.as_str())); + } + } } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -137,11 +187,15 @@ mod test { timestamp: t, version: "grub2-efi-ia32-1:2.12-21.fc41.x86_64,grub2-efi-x64-1:2.12-21.fc41.x86_64,shim-ia32-15.8-3.x86_64,shim-x64-15.8-3.x86_64".into(), versions: None, + #[cfg(efi_arch)] + default_bootloader: None, }; let b = ContentMetadata { timestamp: t + Duration::try_seconds(1).unwrap(), version: "grub2-efi-ia32-1:2.12-28.fc41.x86_64,grub2-efi-x64-1:2.12-28.fc41.x86_64,shim-ia32-15.8-3.x86_64,shim-x64-15.8-3.x86_64".into(), versions: None, + #[cfg(efi_arch)] + default_bootloader: None, }; assert_eq!(a.can_upgrade_to(&b), Ordering::Less); // means upgradable assert_eq!(b.can_upgrade_to(&a), Ordering::Greater); @@ -160,6 +214,8 @@ mod test { rpm_evr: "15.8-3".into(), }, ]), + #[cfg(efi_arch)] + default_bootloader: None, }; let b = ContentMetadata { timestamp: t + Duration::try_seconds(1).unwrap(), @@ -174,6 +230,8 @@ mod test { rpm_evr: "15.8-3".into(), }, ]), + #[cfg(efi_arch)] + default_bootloader: None, }; assert_eq!(a.can_upgrade_to(&b), Ordering::Less); // means upgradable assert_eq!(b.can_upgrade_to(&a), Ordering::Greater); diff --git a/src/model_legacy.rs b/src/model_legacy.rs index a0991922..86f219b4 100644 --- a/src/model_legacy.rs +++ b/src/model_legacy.rs @@ -50,6 +50,8 @@ impl ContentMetadata01 { timestamp, version: self.version, versions: None, + #[cfg(efi_arch)] + default_bootloader: None, } } } diff --git a/src/packagesystem.rs b/src/packagesystem.rs index f20e5e81..c3ee154a 100644 --- a/src/packagesystem.rs +++ b/src/packagesystem.rs @@ -75,6 +75,8 @@ fn rpm_parse_metadata(stdout: &[u8]) -> Result { timestamp: **largest_timestamp, version, versions: Some(modules_vec), + #[cfg(efi_arch)] + default_bootloader: None, }) }