diff --git a/.fmf/version b/.fmf/version new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/.packit.yaml b/.packit.yaml new file mode 100644 index 00000000..5974a61e --- /dev/null +++ b/.packit.yaml @@ -0,0 +1,62 @@ +--- +# See the documentation for more information: +# https://packit.dev/docs/configuration/ + +# name in upstream package repository or registry +upstream_package_name: bootupd +upstream_tag_template: v{version} + +downstream_package_name: rust-bootupd + +specfile_path: contrib/packaging/bootupd.spec + +srpm_build_deps: + - cargo + - git + - libzstd-devel + - openssl-devel + - zstd + +actions: + # The last setp here is required by Packit to return the archive name + # https://packit.dev/docs/configuration/actions#create-archive + create-archive: + - bash -c "cargo install cargo-vendor-filterer" + - bash -c "cargo xtask spec" + - bash -c "cat target/bootupd.spec" + - bash -c "cp target/bootupd* contrib/packaging/" + - bash -c "ls -1 target/bootupd*.tar.zstd | grep -v 'vendor'" + # Do nothing with spec file. Two steps here are for debugging + fix-spec-file: + - bash -c "cat contrib/packaging/bootupd.spec" + - bash -c "ls -al contrib/packaging/" + +jobs: + - job: copr_build + trigger: pull_request + targets: + - fedora-rawhide-aarch64 + - fedora-rawhide-x86_64 + + - job: tests + trigger: pull_request + targets: + - fedora-rawhide-aarch64 + - fedora-rawhide-x86_64 + tmt_plan: /tmt/plans/package + + - job: propose_downstream + trigger: release + dist_git_branches: + fedora-rawhide: + fast_forward_merge_into: [fedora-latest-stable] + + - job: koji_build + trigger: commit + dist_git_branches: + - fedora-all + + - job: bodhi_update + trigger: commit + dist_git_branches: + - fedora-all diff --git a/Cargo.lock b/Cargo.lock index 3be9a944..c5684a36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,7 +33,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -56,6 +71,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.5" @@ -154,7 +178,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3292185c166091432ecbc6b203c0c73953d6b5965908368c51df800f1c549ed" dependencies = [ - "anstream", + "anstream 0.6.21", "anyhow", "chrono", "owo-colors", @@ -446,11 +470,11 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "env_filter", "jiff", diff --git a/Cargo.toml b/Cargo.toml index 8f64a2ba..9056b860 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ openssl = "^0.10" os-release = "0.1.0" regex = "1.12.3" rpm-rs = { package = "rpm", version = "0.16.1", default-features = false, optional = true } -rustix = { version = "1.1.4", features = ["process", "fs"] } +rustix = { version = "1.1.4", features = ["process", "fs", "mount"] } serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" tempfile = "^3.26" diff --git a/contrib/packaging/bootupd.spec b/contrib/packaging/bootupd.spec index a5b5f65d..ce94839e 100644 --- a/contrib/packaging/bootupd.spec +++ b/contrib/packaging/bootupd.spec @@ -9,7 +9,7 @@ Summary: Bootloader updater License: Apache-2.0 URL: https://github.com/coreos/bootupd -Source0: %{crates_source} +Source0: %{url}/releases/download/v%{version}/bootupd-%{version}.tar.zstd Source1: %{url}/releases/download/v%{version}/bootupd-%{version}-vendor.tar.zstd %if 0%{?fedora} || 0%{?rhel} >= 10 ExcludeArch: %{ix86} diff --git a/src/bios.rs b/src/bios.rs index f341f994..1012dd5e 100644 --- a/src/bios.rs +++ b/src/bios.rs @@ -277,4 +277,9 @@ impl Component for Bios { fn get_efi_vendor(&self, _: &Path) -> Result> { Ok(None) } + + /// Package mode copy is EFI-only + fn package_mode_copy_to_boot(&self, _root: &Path) -> Result<()> { + Ok(()) + } } diff --git a/src/bootupd.rs b/src/bootupd.rs index e01ce923..e6094659 100644 --- a/src/bootupd.rs +++ b/src/bootupd.rs @@ -802,6 +802,22 @@ pub(crate) fn client_run_migrate_static_grub_config() -> Result<()> { Ok(()) } +/// Copy bootloader files from /usr/lib/efi to boot/ESP for package mode installations. +pub(crate) fn copy_to_boot(root: &Path) -> Result<()> { + let all_components = get_components_impl(false); + if all_components.is_empty() { + anyhow::bail!("No components available for this platform."); + } + + for component in all_components.values() { + component + .package_mode_copy_to_boot(root) + .with_context(|| format!("Failed to copy component {} to boot", component.name()))?; + } + + Ok(()) +} + /// Writes a stripped GRUB config to `stripped_config_name`, removing lines between /// `### BEGIN /etc/grub.d/15_ostree ###` and `### END /etc/grub.d/15_ostree ###`. fn strip_grub_config_file( diff --git a/src/cli/bootupctl.rs b/src/cli/bootupctl.rs index 1c30b24e..34f0572b 100644 --- a/src/cli/bootupctl.rs +++ b/src/cli/bootupctl.rs @@ -73,6 +73,8 @@ pub enum CtlBackend { Generate(super::bootupd::GenerateOpts), #[clap(name = "install", hide = true)] Install(super::bootupd::InstallOpts), + #[clap(name = "copy-to-boot", hide = true)] + CopyToBoot, } #[derive(Debug, Parser)] @@ -109,6 +111,9 @@ impl CtlCommand { CtlVerb::Backend(CtlBackend::Install(opts)) => { super::bootupd::DCommand::run_install(opts) } + CtlVerb::Backend(CtlBackend::CopyToBoot) => { + super::bootupd::DCommand::run_copy_to_boot() + } CtlVerb::MigrateStaticGrubConfig => Self::run_migrate_static_grub_config(), } } diff --git a/src/cli/bootupd.rs b/src/cli/bootupd.rs index 10e0f256..cab6b580 100644 --- a/src/cli/bootupd.rs +++ b/src/cli/bootupd.rs @@ -38,6 +38,11 @@ pub enum DVerb { GenerateUpdateMetadata(GenerateOpts), #[clap(name = "install", about = "Install components")] Install(InstallOpts), + #[clap( + name = "copy-to-boot", + about = "Copy bootloader files from /usr/lib/efi to ESP (package mode), EFI only" + )] + CopyToBoot, } #[derive(Debug, Parser)] @@ -97,6 +102,7 @@ impl DCommand { match self.cmd { DVerb::Install(opts) => Self::run_install(opts), DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts), + DVerb::CopyToBoot => Self::run_copy_to_boot(), } } @@ -146,4 +152,9 @@ impl DCommand { .context("boot data installation failed")?; Ok(()) } + + pub(crate) fn run_copy_to_boot() -> Result<()> { + bootupd::copy_to_boot(std::path::Path::new("/")).context("copying to boot failed")?; + Ok(()) + } } diff --git a/src/component.rs b/src/component.rs index dabcea97..80a241a7 100644 --- a/src/component.rs +++ b/src/component.rs @@ -85,6 +85,9 @@ pub(crate) trait Component { /// Locating efi vendor dir fn get_efi_vendor(&self, sysroot: &Path) -> Result>; + + /// Copy from /usr/lib/efi to boot/ESP (package mode) + fn package_mode_copy_to_boot(&self, root: &Path) -> Result<()>; } /// Given a component name, create an implementation. diff --git a/src/efi.rs b/src/efi.rs index 1022f4af..11ce6323 100644 --- a/src/efi.rs +++ b/src/efi.rs @@ -19,8 +19,10 @@ use chrono::prelude::*; use fn_error_context::context; use openat_ext::OpenatDirExt; use os_release::OsRelease; -use rustix::{fd::AsFd, fd::BorrowedFd, fs::StatVfsMountFlags}; -use walkdir::WalkDir; +use rustix::mount::{ + fsconfig_create, fsconfig_set_path, fsmount, fsopen, move_mount, FsMountFlags, FsOpenFlags, + MountAttrFlags, MoveMountFlags, UnmountFlags, +}; use widestring::U16CString; use bootc_internal_blockdev::Device; @@ -87,9 +89,40 @@ pub(crate) fn is_efi_booted() -> Result { .map_err(Into::into) } +fn mount_esp(esp_device: &Path, target: &Path) -> Result<()> { + use rustix::fs::CWD; + + let fs_fd = fsopen("vfat", FsOpenFlags::empty()).context("fsopen vfat")?; + fsconfig_set_path(rustix::fd::AsFd::as_fd(&fs_fd), "source", esp_device, CWD) + .context("fsconfig_set_path source")?; + fsconfig_create(rustix::fd::AsFd::as_fd(&fs_fd)).context("fsconfig_create")?; + let mount_fd = fsmount( + rustix::fd::AsFd::as_fd(&fs_fd), + FsMountFlags::empty(), + MountAttrFlags::empty(), + ) + .context("fsmount")?; + let target_dir = std::fs::File::open(target).context("open target dir for move_mount")?; + let target_fd = unsafe { rustix::fd::BorrowedFd::borrow_raw(target_dir.as_raw_fd()) }; + move_mount( + rustix::fd::AsFd::as_fd(&mount_fd), + "", + target_fd, + ".", + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .context("move_mount")?; + Ok(()) +} + +struct Mount { + path: PathBuf, + owned: bool, +} + #[derive(Default)] pub(crate) struct Efi { - mountpoint: RefCell>, + mountpoint: RefCell>, } impl Efi { @@ -120,20 +153,21 @@ impl Efi { break; } } - - // Only borrow mutably if we found a mount point if let Some(mnt) = found_mount { log::debug!("Reusing existing mount point {mnt:?}"); - *self.mountpoint.borrow_mut() = Some(mnt.clone()); + *self.mountpoint.borrow_mut() = Some(Mount { + path: mnt.clone(), + owned: false, + }); Ok(Some(mnt)) } else { Ok(None) } } - // Mount the passed esp_device, return mount point pub(crate) fn mount_esp_device(&self, root: &Path, esp_device: &Path) -> Result { - let mut mountpoint = None; + // (mount path, whether bootupd performed the mount and must unmount) + let mut mountpoint: Option<(PathBuf, bool)> = None; for &mnt in ESP_MOUNTS.iter() { let mnt = root.join(mnt); @@ -147,31 +181,40 @@ impl Efi { if st.f_type == libc::MSDOS_SUPER_MAGIC { if is_mount_point(&mnt)? { log::debug!("ESP already mounted at {mnt:?}, reusing"); - mountpoint = Some(mnt); + mountpoint = Some((mnt, false)); break; } } + if mount_esp(esp_device, &mnt).is_ok() { + log::debug!("Mounted at {mnt:?}"); + mountpoint = Some((mnt, true)); + break; + } + log::trace!("Mount failed, falling back to mount(8)"); std::process::Command::new("mount") - .arg(&esp_device) + .arg(esp_device) .arg(&mnt) .run_inherited() .with_context(|| format!("Failed to mount {:?}", esp_device))?; log::debug!("Mounted at {mnt:?}"); - mountpoint = Some(mnt); + mountpoint = Some((mnt, true)); break; } - let mnt = mountpoint.ok_or_else(|| anyhow::anyhow!("No mount point found"))?; - *self.mountpoint.borrow_mut() = Some(mnt.clone()); + let (mnt, owned) = mountpoint.ok_or_else(|| anyhow::anyhow!("No mount point found"))?; + *self.mountpoint.borrow_mut() = Some(Mount { + path: mnt.clone(), + owned, + }); Ok(mnt) } // Firstly check if esp is already mounted, then mount the passed esp device pub(crate) fn ensure_mounted_esp(&self, root: &Path, esp_device: &Path) -> Result { - if let Some(mountpoint) = self.mountpoint.borrow().as_deref() { - return Ok(mountpoint.to_owned()); + if let Some(mount) = self.mountpoint.borrow().as_ref() { + return Ok(mount.path.clone()); } - let destdir = if let Some(destdir) = self.get_mounted_esp(Path::new(root))? { + let destdir = if let Some(destdir) = self.get_mounted_esp(root)? { destdir } else { self.mount_esp_device(root, esp_device)? @@ -180,12 +223,25 @@ impl Efi { } fn unmount(&self) -> Result<()> { - if let Some(mount) = self.mountpoint.borrow_mut().take() { - Command::new("umount") - .arg(&mount) - .run_inherited() - .with_context(|| format!("Failed to unmount {mount:?}"))?; - log::trace!("Unmounted"); + // Only unmount if we mounted it ourselves + let should_unmount = self + .mountpoint + .borrow() + .as_ref() + .map(|m| m.owned) + .unwrap_or(false); + if should_unmount { + if let Some(mount) = self.mountpoint.borrow_mut().take() { + if rustix::mount::unmount(&mount.path, UnmountFlags::empty()).is_ok() { + log::trace!("Unmounted (new mount API)"); + } else { + Command::new("umount") + .arg(&mount.path) + .run_inherited() + .with_context(|| format!("Failed to unmount {:?}", mount.path))?; + log::trace!("Unmounted"); + } + } } Ok(()) } @@ -205,10 +261,10 @@ impl Efi { let efi = sysroot .open_dir(EFIVARFS.strip_prefix("/").unwrap()) .context("Opening efivars dir")?; - let st = rustix::fs::fstatvfs(efi.as_fd())?; + let st = rustix::fs::fstatvfs(rustix::fd::AsFd::as_fd(&efi))?; // Do nothing if efivars is readonly or empty // See https://github.com/coreos/bootupd/issues/972 - if st.f_flag.contains(StatVfsMountFlags::RDONLY) + if st.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) || std::fs::read_dir(EFIVARFS)?.next().is_none() { log::info!("Skipped EFI variables update: efivars not writable or empty"); @@ -236,6 +292,116 @@ impl Efi { let device_path = device.path(); create_efi_boot_entry(&device_path, esp_part_num.trim(), &loader, &product_name) } + /// Copy EFI components to ESP using the same "write alongside + atomic rename" pattern + /// as bootable container updates, so the system stays bootable if any step fails. + fn copy_efi_components_to_esp( + &self, + sysroot_dir: &openat::Dir, + esp_dir: &openat::Dir, + _esp_path: &Path, + efi_components: &[EFIComponent], + ) -> Result<()> { + // Build a merged source tree in a temp dir (same layout as desired ESP/EFI) + let temp_dir = tempfile::tempdir().context("Creating temp dir for EFI merge")?; + let temp_efi_path = temp_dir.path().join("EFI"); + std::fs::create_dir_all(&temp_efi_path) + .with_context(|| format!("Creating {}", temp_efi_path.display()))?; + let temp_efi_str = temp_efi_path + .to_str() + .context("Temp EFI path is not valid UTF-8")?; + + for efi_comp in efi_components { + log::info!( + "Merging EFI component {} version {} into update tree", + efi_comp.name, + efi_comp.version + ); + let src_efi_contents = format!("{}/.", efi_comp.path); + filetree::copy_dir_with_args( + sysroot_dir, + src_efi_contents.as_str(), + temp_efi_str, + OPTIONS, + ) + .with_context(|| format!("Copying {} to merge dir", efi_comp.path))?; + } + + esp_dir.ensure_dir_all(std::path::Path::new("EFI"), 0o755)?; + let esp_efi_dir = esp_dir.sub_dir("EFI").context("Opening ESP EFI dir")?; + + let source_dir = + openat::Dir::open(&temp_efi_path).context("Opening merged EFI source dir")?; + let source_filetree = + filetree::FileTree::new_from_dir(&source_dir).context("Building source filetree")?; + let current_filetree = + filetree::FileTree::new_from_dir(&esp_efi_dir).context("Building current filetree")?; + let mut diff = current_filetree + .diff(&source_filetree) + .context("Computing EFI diff")?; + diff.removals.clear(); + + // Check available space before writing to prevent partial updates when the partition is full + let required_bytes = current_filetree.total_size() + source_filetree.total_size(); + let available_bytes = util::available_space_bytes(&esp_efi_dir)?; + if available_bytes < required_bytes { + anyhow::bail!( + "ESP has insufficient free space for update: need {} MiB, have {} MiB", + required_bytes / (1024 * 1024), + available_bytes / (1024 * 1024) + ); + } + + // Same logic as bootable container: write to .btmp.* then atomic rename + filetree::apply_diff(&source_dir, &esp_efi_dir, &diff, None) + .context("Applying EFI update (write alongside + atomic rename)")?; + + // Sync the whole ESP filesystem + fsfreeze_thaw_cycle(esp_dir.open_file(".")?)?; + + Ok(()) + } + + /// Copy from /usr/lib/efi to boot/ESP. Caller provides sysroot (e.g. for recovery or tests). + fn package_mode_copy_to_boot_impl(&self, sysroot: &Path) -> Result<()> { + let sysroot_path = Utf8Path::from_path(sysroot) + .with_context(|| format!("Invalid UTF-8: {}", sysroot.display()))?; + + let efi_comps = match get_efi_component_from_usr(sysroot_path, EFILIB)? { + Some(comps) if !comps.is_empty() => comps, + _ => anyhow::bail!("No EFI components found in /usr/lib/efi"), + }; + + // First try to use an already mounted ESP + let esp_path = if let Some(mounted_esp) = self.get_mounted_esp(sysroot)? { + mounted_esp + } else { + let sysroot_cap = Dir::open_ambient_dir(sysroot, cap_std::ambient_authority()) + .with_context(|| format!("Opening sysroot {}", sysroot.display()))?; + let device = bootc_internal_blockdev::list_dev_by_dir(&sysroot_cap) + .with_context(|| format!("Resolving block device for {}", sysroot.display()))?; + let Some(esp_devices) = device.find_colocated_esps()? else { + anyhow::bail!("No ESP found"); + }; + let esp = esp_devices + .first() + .ok_or_else(|| anyhow::anyhow!("No ESP partition found"))?; + self.ensure_mounted_esp(sysroot, Path::new(&esp.path()))? + }; + + let esp_dir = openat::Dir::open(&esp_path) + .with_context(|| format!("Opening ESP at {}", esp_path.display()))?; + validate_esp_fstype(&esp_dir)?; + + let sysroot_dir = openat::Dir::open(sysroot).context("Opening sysroot for reading")?; + self.copy_efi_components_to_esp(&sysroot_dir, &esp_dir, &esp_path, &efi_comps)?; + + log::info!( + "Successfully copied {} EFI component(s) to ESP at {}", + efi_comps.len(), + esp_path.display() + ); + Ok(()) + } } #[context("Get product name")] @@ -473,23 +639,19 @@ impl Component for Efi { } else { None }; - let dest = destpath.to_str().with_context(|| { - format!( - "Include invalid UTF-8 characters in dest {}", - destpath.display() - ) - })?; let efi_path = if let Some(efi_components) = efi_comps { - for efi in efi_components { - filetree::copy_dir_with_args(&src_dir, efi.path.as_str(), dest, OPTIONS)?; - } + // Use shared helper to copy components from /usr/lib/efi + self.copy_efi_components_to_esp(&src_dir, destd, &destpath, &efi_components)?; EFILIB } else { let updates = component_updatedirname(self); let src = updates .to_str() - .context("Include invalid UTF-8 characters in path")?; + .with_context(|| format!("Invalid UTF-8: {}", updates.display()))?; + let dest = destpath + .to_str() + .with_context(|| format!("Invalid UTF-8: {}", destpath.display()))?; filetree::copy_dir_with_args(&src_dir, src, dest, OPTIONS)?; &src.to_owned() }; @@ -686,6 +848,11 @@ impl Component for Efi { anyhow::bail!("Failed to find {SHIM} in the image") } } + + /// Package mode copy: Simple copy from /usr/lib/efi to boot/ESP. + fn package_mode_copy_to_boot(&self, root: &Path) -> Result<()> { + self.package_mode_copy_to_boot_impl(root) + } } impl Drop for Efi { @@ -696,7 +863,7 @@ impl Drop for Efi { } fn validate_esp_fstype(dir: &openat::Dir) -> Result<()> { - let dir = unsafe { BorrowedFd::borrow_raw(dir.as_raw_fd()) }; + let dir = unsafe { rustix::fd::BorrowedFd::borrow_raw(dir.as_raw_fd()) }; let stat = rustix::fs::fstatfs(&dir)?; if stat.f_type != libc::MSDOS_SUPER_MAGIC { bail!( @@ -786,7 +953,10 @@ pub(crate) fn create_efi_boot_entry( fn find_file_recursive>(dir: P, target_file: &str) -> Result> { let mut result = Vec::new(); - for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { + for entry in walkdir::WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + { if entry.file_type().is_file() { if let Some(file_name) = entry.file_name().to_str() { if file_name == target_file { @@ -816,7 +986,7 @@ fn get_efi_component_from_usr<'a>( let efilib_path = sysroot.join(usr_path); let skip_count = Utf8Path::new(usr_path).components().count(); - let mut components: Vec = WalkDir::new(&efilib_path) + let mut components: Vec = walkdir::WalkDir::new(&efilib_path) .min_depth(3) // //EFI: so 3 levels down .max_depth(3) .into_iter() @@ -860,7 +1030,7 @@ fn transfer_ostree_boot_to_usr(sysroot: &Path) -> Result<()> { if !efi.exists() { return Ok(()); } - for entry in WalkDir::new(&efi) { + for entry in walkdir::WalkDir::new(&efi) { let entry = entry?; if entry.file_type().is_file() { @@ -981,7 +1151,6 @@ Boot0003* test"; ); Ok(()) } - #[cfg(test)] fn fixture() -> Result { let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; tempdir.create_dir("etc")?; @@ -1011,7 +1180,7 @@ Boot0003* test"; { tmpd.atomic_write( "etc/system-release", - "Red Hat Enterprise Linux CoreOS release 4 + r"Red Hat Enterprise Linux CoreOS release 4 ", )?; let name = get_product_name(&tmpd)?; @@ -1057,4 +1226,149 @@ Boot0003* test"; assert_eq!(efi_comps, None); Ok(()) } + + #[test] + fn test_package_mode_copy_to_boot_discovery() -> Result<()> { + // Test that we can discover components from /usr/lib/efi + let tmpdir: &tempfile::TempDir = &tempfile::tempdir()?; + let tpath = tmpdir.path(); + let efi_path = tpath.join("usr/lib/efi"); + + // Create mock EFI components + std::fs::create_dir_all(efi_path.join("shim/15.8-3/EFI/fedora"))?; + std::fs::create_dir_all(efi_path.join("grub2/2.12-28/EFI/fedora"))?; + + // Write some test files + std::fs::write( + efi_path.join("shim/15.8-3/EFI/fedora/shimx64.efi"), + b"shim content", + )?; + std::fs::write( + efi_path.join("grub2/2.12-28/EFI/fedora/grubx64.efi"), + b"grub content", + )?; + + let utf8_tpath = + Utf8Path::from_path(tpath).ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?; + + // Test component discovery + let efi_comps = match get_efi_component_from_usr(utf8_tpath, EFILIB)? { + Some(comps) if !comps.is_empty() => comps, + _ => { + anyhow::bail!("Should have found components"); + } + }; + + // Verify we found the expected components + assert_eq!(efi_comps.len(), 2); + let names: Vec<_> = efi_comps.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"shim")); + assert!(names.contains(&"grub2")); + + // Verify paths are correct + for comp in &efi_comps { + assert!(comp.path.starts_with("usr/lib/efi")); + assert!(comp.path.ends_with("EFI")); + } + + Ok(()) + } + + #[test] + fn test_package_mode_shim_installation() -> Result<()> { + // Test that shim can be installed from /usr/lib/efi to ESP + let tmpdir: &tempfile::TempDir = &tempfile::tempdir()?; + let tpath = tmpdir.path(); + + // Create mock /usr/lib/efi structure with shim + let efi_path = tpath.join("usr/lib/efi"); + let shim_path = efi_path.join("shim/15.8-3/EFI/fedora"); + std::fs::create_dir_all(&shim_path)?; + + // Write shim binary + let shim_content = b"mock shim binary content"; + std::fs::write(shim_path.join(SHIM), shim_content)?; + + // Create additional shim files that might be present + std::fs::write(shim_path.join("MokManager.efi"), b"mok manager content")?; + std::fs::write(shim_path.join("fbx64.efi"), b"fallback content")?; + + // Create mock ESP directory structure (simulating /boot/efi in container) + let esp_path = tpath.join("boot/efi"); + std::fs::create_dir_all(&esp_path)?; + + // Create EFI directory in ESP + let esp_efi_path = esp_path.join("EFI"); + std::fs::create_dir_all(&esp_efi_path)?; + + // Set up sysroot directory + let sysroot_dir = openat::Dir::open(tpath)?; + + // Get EFI components from usr/lib/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)?; + assert!(efi_comps.is_some(), "Should find shim component"); + let efi_comps = efi_comps.unwrap(); + assert_eq!(efi_comps.len(), 1, "Should find exactly one component"); + assert_eq!(efi_comps[0].name, "shim"); + assert_eq!(efi_comps[0].version, "15.8-3"); + + // Create Efi instance and copy components to ESP + let esp_dir = openat::Dir::open(&esp_path).context("Opening ESP dir for test")?; + let efi = Efi::default(); + efi.copy_efi_components_to_esp(&sysroot_dir, &esp_dir, &esp_path, &efi_comps)?; + + // Expected path: /boot/efi/EFI/fedora/shimx64.efi (or shimaa64.efi, etc.) + let copied_shim_path = esp_path.join("EFI/fedora").join(SHIM); + assert!( + copied_shim_path.exists(), + "Shim should be copied to ESP at {}", + copied_shim_path.display() + ); + + // Verify the shim file is actually a file, not a directory + assert!( + copied_shim_path.is_file(), + "Shim should be a file at {}", + copied_shim_path.display() + ); + + // Verify the content matches exactly + let copied_content = std::fs::read(&copied_shim_path)?; + assert_eq!( + copied_content, shim_content, + "Shim content should match exactly" + ); + + // Verify the directory structure is correct + assert!( + esp_path.join("EFI").exists(), + "EFI directory should exist in ESP at {}", + esp_path.join("EFI").display() + ); + assert!(esp_path.join("EFI").is_dir(), "EFI should be a directory"); + + assert!( + esp_path.join("EFI/fedora").exists(), + "Vendor directory (fedora) should exist in ESP at {}", + esp_path.join("EFI/fedora").display() + ); + assert!( + esp_path.join("EFI/fedora").is_dir(), + "EFI/fedora should be a directory" + ); + + // Verify the path structure matches expected package mode layout + // Source: /usr/lib/efi/shim/15.8-3/EFI/fedora/shimx64.efi + // Dest: /boot/efi/EFI/fedora/shimx64.efi + let expected_base = esp_path.join("EFI/fedora"); + assert_eq!( + copied_shim_path.parent(), + Some(expected_base.as_path()), + "Shim should be directly under EFI/fedora/, not in a subdirectory" + ); + + Ok(()) + } } diff --git a/src/filetree.rs b/src/filetree.rs index a9eee816..b6eb8bc1 100644 --- a/src/filetree.rs +++ b/src/filetree.rs @@ -207,6 +207,16 @@ impl FileTree { Ok(Self { children }) } + /// Total size in bytes of all files in the tree (for space checks). + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn total_size(&self) -> u64 { + self.children.values().map(|m| m.size).sum() + } + /// Determine the changes *from* self to the updated tree #[cfg(any( target_arch = "x86_64", diff --git a/src/util.rs b/src/util.rs index 2fda1778..4d36edca 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,9 +1,11 @@ use std::collections::HashSet; +use std::os::unix::io::AsRawFd; use std::path::Path; use std::process::Command; use anyhow::{bail, Context, Result}; use openat_ext::OpenatDirExt; +use rustix::fd::BorrowedFd; /// Parse an environment variable as UTF-8 #[allow(dead_code)] @@ -51,9 +53,18 @@ pub(crate) fn filenames(dir: &openat::Dir) -> Result> { Ok(ret) } +/// Return the available space in bytes on the filesystem containing the given directory. +/// Uses f_bavail * f_frsize from fstatvfs to avoid partial updates when the partition is full. +pub(crate) fn available_space_bytes(dir: &openat::Dir) -> Result { + let fd = unsafe { BorrowedFd::borrow_raw(dir.as_raw_fd()) }; + let st = rustix::fs::fstatvfs(fd)?; + Ok((st.f_bavail as u64) * (st.f_frsize as u64)) +} + pub(crate) fn ensure_writable_mount>(p: P) -> Result<()> { let p = p.as_ref(); - let stat = rustix::fs::statvfs(p)?; + let stat = + rustix::fs::statvfs(p).map_err(|e| std::io::Error::from_raw_os_error(e.raw_os_error()))?; if !stat.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) { return Ok(()); } diff --git a/tmt/plans/package.fmf b/tmt/plans/package.fmf new file mode 100644 index 00000000..788af8ee --- /dev/null +++ b/tmt/plans/package.fmf @@ -0,0 +1,25 @@ +discover: + how: fmf +execute: + how: tmt +provision: + how: virtual + hardware: + boot: + method: uefi + image: $@{test_image} + user: root +prepare: + # Run on package mode VM especially on Fedora + - how: install + order: 20 + package: + - bootupd + + +/plan-test-copy-to-boot: + summary: Execute copy-to-boot test on package mode + discover: + how: fmf + test: + - /tmt/tests/tests/test-copy-to-boot diff --git a/tmt/tests/package/test-copy-to-boot.sh b/tmt/tests/package/test-copy-to-boot.sh new file mode 100644 index 00000000..3f7f96a5 --- /dev/null +++ b/tmt/tests/package/test-copy-to-boot.sh @@ -0,0 +1,72 @@ +# number: 10 +# tmt: +# summary: Test copy-to-boot on package mode +# duration: 10m +# +#!/bin/bash +set -eux + +echo "Testing copy-to-boot on package mode" + +rpm -q bootupd + +source /etc/os-release +if [ "$ID" == "fedora" ] && [ "$VERSION_ID" -lt 44 ]; then + echo "Skip testing on F43 and older" + exit 0 +fi + +suffix="" +get_grub_suffix() { + case "$(uname -m)" in + x86_64) + suffix="x64" + ;; + aarch64) + suffix="aa64" + ;; + *) + echo "Unsupported arch" + exit 1 + ;; + esac +} + +if [ "$TMT_REBOOT_COUNT" -eq 0 ]; then + echo 'Before first reboot' + # assume ESP is already mounted at /boot/efi + mountpoint /boot/efi + get_grub_suffix + grubefi="grub${suffix}.efi" + + grub_source_path=$(find /usr/lib/efi/ -name "${grubefi}") + if [ -z "${grub_source_path}" ]; then + echo "Error: Source GRUB binary ${grub_source_path} not found." + exit 1 + fi + + grub_target_path=/boot/efi/EFI/fedora/${grubefi} + if [ ! -f "${grub_target_path}" ]; then + echo "Error: Could not find target GRUB binary ${grub_target_path}." + exit 1 + fi + + # change grub.efi and it will be synced after copy-to-boot + echo test > "${grub_target_path}" + bootupctl backend copy-to-boot + sync + + # get checksum from source /usr/lib/efi/grub2/xx/EFI/fedora/grub.efi + source_checksum=$(sha256sum "${grub_source_path}" | cut -d' ' -f1) + # get checksum from target /boot/efi/EFI/fedora/grub.efi + target_checksum=$(sha256sum "${grub_target_path}" | cut -d' ' -f1) + # confirm that the target grub.efi is updated + [ "${source_checksum}" == "${target_checksum}" ] + tmt-reboot +elif [ "$TMT_REBOOT_COUNT" -eq 1 ]; then + echo 'After the reboot' + # just confirm the reboot is successful + whoami +fi + +echo "Run copy-to-boot test successfully" diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf new file mode 100644 index 00000000..b047c44f --- /dev/null +++ b/tmt/tests/tests.fmf @@ -0,0 +1,7 @@ +/test-copy-to-boot: + summary: Test copy-to-boot on package mode + duration: 10m + adjust: + - when: distro != fedora + enabled: false + test: bash package/test-copy-to-boot.sh diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d985b35c..a07d11ca 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -32,6 +32,7 @@ fn try_main() -> Result<()> { "vendor" => vendor, "package" => package, "package-srpm" => package_srpm, + "spec" => spec, _ => print_help, }; f(&sh)?; @@ -155,10 +156,9 @@ fn impl_package(sh: &Shell) -> Result { ) .run()?; } - // Compress with gzip and write to crate - let srcpath: Utf8PathBuf = Utf8Path::new("target").join(format!("{namev}.crate")); - cmd!(sh, "gzip --force --best {p}").run()?; - std::fs::rename(format!("{p}.gz"), &srcpath)?; + // Compress with zstd + let srcpath: Utf8PathBuf = format!("{p}.zstd").into(); + cmd!(sh, "zstd --rm -f {p} -o {srcpath}").run()?; Ok(Package { version: v, @@ -243,6 +243,42 @@ fn package_srpm(sh: &Shell) -> Result<()> { Ok(()) } +fn update_spec(sh: &Shell) -> Result { + let _targetdir = get_target_dir()?; + let p = Utf8Path::new("target"); + let pkg = impl_package(sh)?; + let srcpath = pkg.srcpath.file_name().unwrap(); + let v = pkg.version; + let src_vendorpath = pkg.vendorpath.file_name().unwrap(); + { + let specin = File::open(format!("contrib/packaging/{NAME}.spec")) + .map(BufReader::new) + .context("Opening spec")?; + let mut o = File::create(p.join(format!("{NAME}.spec"))).map(BufWriter::new)?; + for line in specin.lines() { + let line = line?; + if line.starts_with("Version:") { + writeln!(o, "# Replaced by cargo xtask spec")?; + writeln!(o, "Version: {v}")?; + } else if line.starts_with("Source0") { + writeln!(o, "Source0: {srcpath}")?; + } else if line.starts_with("Source1") { + writeln!(o, "Source1: {src_vendorpath}")?; + } else { + writeln!(o, "{line}")?; + } + } + } + let spec_path = p.join(format!("{NAME}.spec")); + Ok(spec_path) +} + +fn spec(sh: &Shell) -> Result<()> { + let s = update_spec(sh)?; + println!("Generated: {s}"); + Ok(()) +} + fn print_help(_sh: &Shell) -> Result<()> { eprintln!( "Tasks: