From ad316e2a5a797b3a64f643924a5aef2acc30ae46 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 28 Sep 2026 14:05:23 +0200 Subject: [PATCH] ephemeral: Boot aboot artifacts from bootc images Prefer /boot/aboot-*.img when present and extract either a ukiboot UKI or an Android boot image for direct QEMU boot. Check extraction tools check each tool only when needed. Assisted-by: AI Signed-off-by: Alexander Larsson --- contrib/packaging/bcvk.spec | 1 + crates/kit/src/kernel.rs | 168 +++++++++++++++++++++++++++----- crates/kit/src/run_ephemeral.rs | 106 +++++++++++--------- docs/src/installation.md | 3 +- 4 files changed, 206 insertions(+), 72 deletions(-) diff --git a/contrib/packaging/bcvk.spec b/contrib/packaging/bcvk.spec index 4309db68e..dd3e7392b 100644 --- a/contrib/packaging/bcvk.spec +++ b/contrib/packaging/bcvk.spec @@ -22,6 +22,7 @@ Requires: virtiofsd # Extraction and initramfs tools are needed for some ephemeral boot images. Recommends: binutils Recommends: kernel-tools +Recommends: android-tools # libvirt-client is optional but recommended for 'bcvk libvirt' commands Recommends: libvirt-client diff --git a/crates/kit/src/kernel.rs b/crates/kit/src/kernel.rs index 5804c8b95..f0bcd2810 100644 --- a/crates/kit/src/kernel.rs +++ b/crates/kit/src/kernel.rs @@ -1,9 +1,10 @@ //! Kernel detection for container images. //! //! This module provides functionality to detect kernel and initramfs in container -//! images, supporting both traditional kernels (with separate vmlinuz/initrd) and -//! Unified Kernel Images (UKI). +//! images, supporting traditional kernels, Unified Kernel Images (UKI), and +//! aboot artifacts. +use std::io::Read; use std::path::Path; use camino::{Utf8Path, Utf8PathBuf}; @@ -26,31 +27,63 @@ const VMLINUZ: &str = "vmlinuz"; /// Traditional initramfs filename const INITRAMFS: &str = "initramfs.img"; +/// Aboot artifacts use this name regardless of whether they contain a UKI or +/// an Android boot image. +const ABOOT_PREFIX: &str = "aboot-"; +const ABOOT_EXTENSION: &str = "img"; +const ANDROID_BOOT_MAGIC: &[u8; 8] = b"ANDROID!"; +const PE_MAGIC: &[u8; 2] = b"MZ"; + +/// Format of the boot artifact from which QEMU obtains its kernel and initramfs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KernelKind { + /// Separate vmlinuz and initramfs files. + Traditional, + /// Unified Kernel Image, including ukiboot aboot payloads. + Uki, + /// Android boot image containing a kernel and ramdisk. + AndroidBoot, +} + /// Information about a kernel found in a container image. #[derive(Debug, Clone)] pub struct KernelInfo { - /// Path to the kernel (vmlinuz or UKI .efi file) + /// Path to the kernel or combined boot artifact. pub kernel_path: Utf8PathBuf, - /// Path to the initramfs (only for traditional kernels, None for UKI) + /// Path to the initramfs (only for traditional kernels). pub initramfs_path: Option, - /// Whether this is a Unified Kernel Image - pub is_uki: bool, + /// Format of the discovered boot artifact. + pub kind: KernelKind, } /// Find kernel/initramfs in a container image root directory. /// -/// UKIs take precedence over traditional kernels. This handles older images -/// that may have both a UKI and vmlinuz+initramfs. +/// Aboot artifacts take precedence over UKIs, which take precedence over +/// traditional kernels. An aboot image may retain other kernel files. /// /// Search order: -/// 1. `/boot/EFI/Linux/*.efi` - UKI in ESP -/// 2. `/usr/lib/modules//*.efi` - UKI alongside modules -/// 3. `/usr/lib/modules//vmlinuz` + `initramfs.img` - traditional +/// 1. `/boot/aboot-*.img` - ukiboot UKI or Android boot image +/// 2. `/boot/EFI/Linux/*.efi` - UKI in ESP +/// 3. `/usr/lib/modules//*.efi` - UKI alongside modules +/// 4. `/usr/lib/modules//vmlinuz` + `initramfs.img` - traditional /// -/// Returns an error if multiple UKIs are found, or if no UKI exists and -/// multiple traditional kernels are found. +/// Returns an error if multiple artifacts of the preferred kind are found. /// Returns `None` if no kernel is found. pub fn find_kernel(root: &Dir) -> Result> { + let aboot = find_aboot_artifacts(root)?; + match aboot.as_slice() { + [] => {} + [artifact] => return Ok(Some(artifact.clone())), + artifacts => { + let paths: Vec<_> = artifacts.iter().map(|k| k.kernel_path.as_str()).collect(); + bail!( + "Found {} aboot artifacts, expected exactly one:\n {}", + artifacts.len(), + paths.join("\n ") + ); + } + } + // First, collect all UKIs let mut ukis: Vec = Vec::new(); ukis.extend(find_ukis_in_esp(root)?); @@ -86,6 +119,50 @@ pub fn find_kernel(root: &Dir) -> Result> { } } +fn find_aboot_artifacts(root: &Dir) -> Result> { + let Some(boot) = root.open_dir_optional("boot")? else { + return Ok(Vec::new()); + }; + let mut artifacts = Vec::new(); + for entry in boot.entries()? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some((stem, extension)) = name.rsplit_once('.') else { + continue; + }; + let Some(version) = stem.strip_prefix(ABOOT_PREFIX) else { + continue; + }; + if version.is_empty() || extension != ABOOT_EXTENSION { + continue; + } + + let mut file = boot.open(name)?; + let mut magic = [0u8; 8]; + file.read_exact(&mut magic) + .with_context(|| format!("reading header of boot/{name}"))?; + let kind = if &magic == ANDROID_BOOT_MAGIC { + KernelKind::AndroidBoot + } else if magic.starts_with(PE_MAGIC) { + KernelKind::Uki + } else { + bail!("Unsupported aboot artifact format: boot/{name}"); + }; + artifacts.push(KernelInfo { + kernel_path: Utf8PathBuf::from(format!("boot/{name}")), + initramfs_path: None, + kind, + }); + } + Ok(artifacts) +} + /// Check if a filename has the UKI extension (.efi) fn is_uki_file(name: &std::ffi::OsStr) -> bool { Path::new(name) @@ -111,7 +188,7 @@ fn find_ukis_in_esp(root: &Dir) -> Result> { ukis.push(KernelInfo { kernel_path: Utf8PathBuf::from(format!("boot/{EFI_LINUX}/{name_str}")), initramfs_path: None, - is_uki: true, + kind: KernelKind::Uki, }); } } @@ -155,7 +232,7 @@ fn find_ukis_in_modules(root: &Dir) -> Result> { "usr/lib/{MODULES_DIR}/{version}/{uki_name}" )), initramfs_path: None, - is_uki: true, + kind: KernelKind::Uki, }); } } @@ -192,7 +269,7 @@ fn find_traditional_kernels_in_modules(root: &Dir) -> Result> { initramfs_path: Some(Utf8PathBuf::from(format!( "usr/lib/{MODULES_DIR}/{version}/{INITRAMFS}" ))), - is_uki: false, + kind: KernelKind::Traditional, }); } } @@ -225,7 +302,7 @@ pub fn with_root_prefix(info: KernelInfo, root: &Utf8Path) -> KernelInfo { KernelInfo { kernel_path: root.join(&info.kernel_path), initramfs_path: info.initramfs_path.map(|p| root.join(&p)), - is_uki: info.is_uki, + kind: info.kind, } } @@ -256,7 +333,7 @@ mod tests { )?; let info = find_kernel(&tempdir)?.expect("should find kernel"); - assert!(!info.is_uki); + assert_eq!(info.kind, KernelKind::Traditional); assert!(info.kernel_path.as_str().contains("vmlinuz")); assert!(info.initramfs_path.is_some()); assert!(info @@ -275,7 +352,7 @@ mod tests { tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", b"fake uki")?; let info = find_kernel(&tempdir)?.expect("should find kernel"); - assert!(info.is_uki); + assert_eq!(info.kind, KernelKind::Uki); assert!(info.kernel_path.as_str().contains("fedora-6.12.0.efi")); assert!(info.initramfs_path.is_none()); Ok(()) @@ -291,7 +368,7 @@ mod tests { )?; let info = find_kernel(&tempdir)?.expect("should find kernel"); - assert!(info.is_uki); + assert_eq!(info.kind, KernelKind::Uki); assert!(info .kernel_path .as_str() @@ -322,7 +399,7 @@ mod tests { // Should find the UKI, ignoring traditional kernel let info = find_kernel(&tempdir)?.expect("should find kernel"); - assert!(info.is_uki); + assert_eq!(info.kind, KernelKind::Uki); assert!(info.kernel_path.as_str().contains("fedora-6.12.0.efi")); Ok(()) } @@ -349,7 +426,7 @@ mod tests { // Should find the UKI, ignoring traditional kernel let info = find_kernel(&tempdir)?.expect("should find kernel"); - assert!(info.is_uki); + assert_eq!(info.kind, KernelKind::Uki); assert!(info .kernel_path .as_str() @@ -451,12 +528,55 @@ mod tests { assert!(err.contains("Found 2 UKIs")); } + #[test] + fn test_find_aboot_artifact() -> Result<()> { + for (header, kind) in [ + (b"ANDROID!".as_slice(), KernelKind::AndroidBoot), + (b"MZ______".as_slice(), KernelKind::Uki), + ] { + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir_all("boot")?; + root.atomic_write("boot/aboot-6.12.img", header)?; + root.atomic_write("boot/aboot-.img", b"")?; + root.atomic_write("boot/aboot-6.12.img.bak", b"")?; + root.atomic_write("boot/vbmeta-6.12.img", b"vbmeta")?; + root.create_dir_all("usr/lib/modules/6.12")?; + root.atomic_write("usr/lib/modules/6.12/vmlinuz", b"kernel")?; + root.atomic_write("usr/lib/modules/6.12/initramfs.img", b"initramfs")?; + + let info = find_kernel(&root)?.expect("aboot artifact should take precedence"); + assert_eq!(info.kind, kind); + assert_eq!(info.kernel_path, Utf8Path::new("boot/aboot-6.12.img")); + assert!(info.initramfs_path.is_none()); + } + Ok(()) + } + + #[test] + fn test_find_aboot_artifact_errors() -> Result<()> { + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir_all("boot")?; + root.atomic_write("boot/aboot-6.12.img", b"unknown!")?; + assert!(find_kernel(&root) + .unwrap_err() + .to_string() + .contains("Unsupported aboot artifact format")); + + root.atomic_write("boot/aboot-6.12.img", b"ANDROID!")?; + root.atomic_write("boot/aboot-6.13.img", b"ANDROID!")?; + assert!(find_kernel(&root) + .unwrap_err() + .to_string() + .contains("Found 2 aboot artifacts")); + Ok(()) + } + #[test] fn test_with_root_prefix() { let info = KernelInfo { kernel_path: Utf8PathBuf::from("boot/EFI/Linux/test.efi"), initramfs_path: None, - is_uki: true, + kind: KernelKind::Uki, }; let prefixed = with_root_prefix(info, Utf8Path::new("/run/source-image")); @@ -471,7 +591,7 @@ mod tests { let info = KernelInfo { kernel_path: Utf8PathBuf::from("usr/lib/modules/6.12.0/vmlinuz"), initramfs_path: Some(Utf8PathBuf::from("usr/lib/modules/6.12.0/initramfs.img")), - is_uki: false, + kind: KernelKind::Traditional, }; let prefixed = with_root_prefix(info, Utf8Path::new("/run/source-image")); diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 319157a4e..4e408c963 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -1219,10 +1219,9 @@ fn parse_service_exit_code(status_content: &str) -> Result { /// not the guest bootc image that gets booted inside the VM. fn check_required_container_binaries() -> Result<()> { // systemctl: used for checking cloud-init and other systemd operations - // objcopy: for UKI kernel extraction (when using UKI images) // NOTE: mount and chroot are checked earlier in entrypoint.sh, not here, because by the // time run_impl() executes we're already inside the hybrid root - let required_binaries = ["systemctl", "objcopy"]; + let required_binaries = ["systemctl"]; let mut missing = Vec::new(); @@ -1409,66 +1408,74 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> { .ok_or_else(|| { eyre!( "No kernel found. Checked:\n\ + - /boot/aboot-*.img (ukiboot or Android boot)\n\ - /boot/EFI/Linux/*.efi (UKI)\n\ - /usr/lib/modules//.efi (UKI)\n\ - /usr/lib/modules//vmlinuz + initramfs.img" ) })?; - // Add the source-image prefix to get absolute paths let kernel_info = crate::kernel::with_root_prefix(kernel_info, Utf8Path::new("/run/source-image")); debug!( - "Found kernel: {:?} (UKI: {})", - kernel_info.kernel_path, kernel_info.is_uki + "Found kernel: {:?} ({:?})", + kernel_info.kernel_path, kernel_info.kind ); let kernel_mount = "/run/qemu/kernel"; let initramfs_mount = "/run/qemu/initramfs"; - // Extract from UKI if found, otherwise use traditional kernel - if kernel_info.is_uki { - debug!( - "Extracting kernel and initramfs from UKI: {:?}", - kernel_info.kernel_path - ); - - // Extract .linux section (kernel) from UKI - Command::new("objcopy") - .args([ - "--dump-section", - &format!(".linux={}", kernel_mount), - kernel_info.kernel_path.as_str(), - ]) - .run_capture_stderr() - .map_err(|e| eyre!("Failed to extract kernel from UKI: {e}"))?; - debug!("Extracted kernel from UKI to {}", kernel_mount); - - // Extract .initrd section (initramfs) from UKI - Command::new("objcopy") - .args([ - "--dump-section", - &format!(".initrd={}", initramfs_mount), - kernel_info.kernel_path.as_str(), - ]) - .run_capture_stderr() - .map_err(|e| eyre!("Failed to extract initramfs from UKI: {e}"))?; - debug!("Extracted initramfs from UKI to {}", initramfs_mount); - } else { - let source_initramfs_path = kernel_info - .initramfs_path - .as_ref() - .ok_or_else(|| eyre!("Traditional kernel found but no initramfs path"))?; - - // Copy kernel; a bind mount would be slightly cheaper but can fail with - // EPERM on newer kernels due to locked-mount restrictions in user namespaces. - fs::copy(&kernel_info.kernel_path, kernel_mount) - .map_err(|e| eyre!("Failed to copy kernel: {e}"))?; - - // Copy initramfs so we can append to it - fs::copy(source_initramfs_path, initramfs_mount) - .map_err(|e| eyre!("Failed to copy initramfs: {e}"))?; + match kernel_info.kind { + crate::kernel::KernelKind::Uki => { + require_binary("objcopy")?; + debug!( + "Extracting kernel and initramfs from UKI: {:?}", + kernel_info.kernel_path + ); + for (section, output) in [(".linux", kernel_mount), (".initrd", initramfs_mount)] { + Command::new("objcopy") + .args([ + "--dump-section", + &format!("{section}={output}"), + kernel_info.kernel_path.as_str(), + ]) + .run_capture_stderr() + .map_err(|e| eyre!("Failed to extract {section} from UKI: {e}"))?; + } + } + crate::kernel::KernelKind::AndroidBoot => { + require_binary("unpack_bootimg")?; + let unpacked = "/run/qemu/aboot-unpacked"; + fs::create_dir_all(unpacked)?; + Command::new("unpack_bootimg") + .args([ + "--boot_img", + kernel_info.kernel_path.as_str(), + "--out", + unpacked, + ]) + .stdout(Stdio::null()) + .run_capture_stderr() + .map_err(|e| eyre!("Failed to unpack Android boot image: {e}"))?; + fs::rename(format!("{unpacked}/kernel"), kernel_mount) + .context("Getting kernel from Android boot image")?; + fs::rename(format!("{unpacked}/ramdisk"), initramfs_mount) + .context("Getting ramdisk from Android boot image")?; + } + crate::kernel::KernelKind::Traditional => { + let source_initramfs_path = kernel_info + .initramfs_path + .as_ref() + .ok_or_else(|| eyre!("Traditional kernel found but no initramfs path"))?; + + // A bind mount can fail with EPERM on newer kernels due to + // locked-mount restrictions in user namespaces. + fs::copy(&kernel_info.kernel_path, kernel_mount) + .map_err(|e| eyre!("Failed to copy kernel: {e}"))?; + fs::copy(source_initramfs_path, initramfs_mount) + .map_err(|e| eyre!("Failed to copy initramfs: {e}"))?; + } } // ARM64 kernels have no built-in decompressors, which means Qemu has to decompress @@ -1487,6 +1494,11 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> { .map_err(|e| eyre!("Unwrapping ARM64 zstd EFI zboot kernel: {e}"))?; fs::rename(unwrapped, kernel_mount)?; } + for path in [kernel_mount, initramfs_mount] { + if fs::metadata(path)?.len() == 0 { + return Err(eyre!("Extracted boot file is empty: {path}")); + } + } remove_bootconfig(initramfs_mount)?; diff --git a/docs/src/installation.md b/docs/src/installation.md index d03f56b96..be2f960d3 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -22,6 +22,8 @@ For running bcvk: - virtiofsd - Podman - openssh-clients (for libvirt SSH operations) +- binutils (for extracting UKIs, including ukiboot payloads) +- android-tools (for extracting Android boot images) - kernel-tools (for initramfs bootconfig and ARM64 zstd EFI zboot kernels) Optional: @@ -35,7 +37,6 @@ Optional: For `bcvk ephemeral` operations, the bootc container images you run must contain: - systemctl (systemd) -- objcopy (binutils) - mount (util-linux), chroot (coreutils) - ssh, ssh-keygen (openssh-clients)