diff --git a/crates/ecr-cli/src/cli.rs b/crates/ecr-cli/src/cli.rs index 40a4806..537659d 100644 --- a/crates/ecr-cli/src/cli.rs +++ b/crates/ecr-cli/src/cli.rs @@ -36,7 +36,8 @@ pub struct Args { /// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default) /// /// Examples: - /// --kernel Download and use the default Alpine linux-virt kernel + /// --kernel Download and use the default Alpine kernel + /// (linux-virt, or linux-lts on riscv64) /// --kernel=./vmlinuz Use a specific kernel file /// /// The kernel path must use `=` syntax: with `--kernel ./vmlinuz` the diff --git a/crates/ecr/src/kernel.rs b/crates/ecr/src/kernel.rs index 5f88b2b..cc244ea 100644 --- a/crates/ecr/src/kernel.rs +++ b/crates/ecr/src/kernel.rs @@ -1,22 +1,32 @@ //! Default kernel download for QEMU VM mode. //! //! When `--kernel` is specified without a path, we download a default kernel -//! suitable for VM booting. We use Alpine's `linux-virt` package because: +//! suitable for VM booting from Alpine's kernel packages: //! //! - Small size (~10-15MB compressed) -//! - VM-optimized configuration +//! - VM-optimized configuration (`linux-virt`) //! - Multi-architecture support //! - Simple direct download URLs //! -//! The kernel is cached in the same cache directory as rootfs images. +//! `linux-virt` is preferred, but it is not built for every architecture +//! (riscv64 has no virt flavor in `main`), so we fall back to `linux-lts` +//! when the virt flavor is absent from the repository index. +//! +//! The kernel is cached in the same cache directory as rootfs images, +//! decompressed: some architectures (aarch64, riscv64) package their +//! kernel image gzipped, and QEMU's riscv `-kernel` loader understands +//! only ELF, uImage and raw images. use crate::veprintln; use anyhow::{anyhow, Context, Result}; use indicatif::{ProgressBar, ProgressStyle}; -use std::io::Read; +use std::io::{Read, Seek}; use std::path::{Path, PathBuf}; use std::time::Duration; +/// Alpine kernel packages to consider, most preferred first +const KERNEL_PACKAGES: [&str; 2] = ["linux-virt", "linux-lts"]; + /// Alpine architecture mapping for kernel packages fn alpine_kernel_arch(arch: &str) -> &'static str { match arch { @@ -91,8 +101,11 @@ fn fetch_alpine_branch_from_yaml() -> Result { Err(anyhow!("Could not determine Alpine version from releases")) } -/// Fetch the latest linux-virt package version from Alpine's package index -fn get_linux_virt_version(branch: &str, arch: &str) -> Result { +/// Find the newest kernel package among `KERNEL_PACKAGES` in Alpine's +/// package index. Returns the package name and version. The index is +/// scanned once; whichever candidate flavor appears with the highest +/// preference (lowest index) wins. +fn find_kernel_package(branch: &str, arch: &str) -> Result<(&'static str, String)> { // Alpine package index URL let url = format!( "https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz", @@ -173,25 +186,45 @@ fn get_linux_virt_version(branch: &str, arch: &str) -> Result { veprintln!(" APKINDEX size: {} bytes", contents.len()); - // Parse the APKINDEX to find linux-virt + // Parse the APKINDEX to find the best kernel package // Format: // P:linux-virt // V:6.12.8-r0 // ... + // The first V: line of the highest-preference package wins; the + // earliest version entry in the index is the latest build. let mut pkg_name: Option = None; + // (preference index, package, version) + let mut best: Option<(usize, &'static str, String)> = None; for line in contents.lines() { if let Some(name) = line.strip_prefix("P:") { pkg_name = Some(name.trim().to_string()); } else if let Some(version) = line.strip_prefix("V:") { - if pkg_name.as_deref() == Some("linux-virt") { - return Ok(version.trim().to_string()); + if let Some(name) = &pkg_name { + if let Some(idx) = KERNEL_PACKAGES.iter().position(|p| *p == name) { + let preferred = best + .as_ref() + .map_or(true, |(best_idx, _, _)| idx < *best_idx); + if preferred { + best = + Some((idx, KERNEL_PACKAGES[idx], version.trim().to_string())); + } + } } } } - // If we got here, we found APKINDEX but not linux-virt - return Err(anyhow!("linux-virt package not found in APKINDEX. Available packages may vary by architecture.")); + match best { + Some((_, pkg, version)) => return Ok((pkg, version)), + None => { + return Err(anyhow!( + "no kernel package found in APKINDEX (looked for: {}). \ + Available packages may vary by architecture.", + KERNEL_PACKAGES.join(", ") + )) + } + } } } @@ -203,16 +236,16 @@ fn get_linux_virt_version(branch: &str, arch: &str) -> Result { )) } -/// Download and extract the linux-virt kernel from Alpine's package repository +/// Download and extract the kernel from an Alpine package repository fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> { - let version = get_linux_virt_version(branch, arch)?; - veprintln!("Found linux-virt version: {}", version); + let (package, version) = find_kernel_package(branch, arch)?; + veprintln!("Found {} version: {}", package, version); - // Construct the download URL for the linux-virt .apk + // Construct the download URL for the kernel .apk // Format: https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/linux-virt-6.12.8-r0.apk let url = format!( - "https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/linux-virt-{}.apk", - branch, arch, version + "https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/{}-{}.apk", + branch, arch, package, version ); veprintln!("Downloading kernel: {}", url); @@ -221,12 +254,12 @@ fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> { let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; rt.block_on(download_kernel_async(&url, dest))?; - // Extract vmlinuz-virt from the APK - // APK files are gzip-compressed tar archives + // Extract the kernel image from the APK, then store it decompressed. + // Remove the ~45MB APK whatever the outcome — don't leave it behind + // in the cache directory on failure. let temp_apk = dest.with_extension("apk"); - // Remove the ~45MB APK whatever the extraction outcome — don't leave - // it behind in the cache directory on failure. - let result = extract_kernel_from_apk(&temp_apk, dest); + let result = extract_kernel_from_apk(&temp_apk, dest, package) + .and_then(|()| decompress_kernel_if_gzipped(dest)); std::fs::remove_file(&temp_apk).ok(); result } @@ -294,8 +327,15 @@ async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> { Ok(()) } -/// Extract vmlinuz-virt from an Alpine APK file -fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> { +/// Extract the kernel image from an Alpine APK file +fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> Result<()> { + // The kernel binary is named after the flavor: boot/vmlinuz-virt for + // linux-virt, boot/vmlinuz-lts for linux-lts + let flavor = package + .strip_prefix("linux-") + .ok_or_else(|| anyhow!("unexpected kernel package name: {}", package))?; + let kernel_name = format!("boot/vmlinuz-{}", flavor); + veprintln!("Extracting kernel from APK..."); let file = std::fs::File::open(apk_path).context("Failed to open APK file")?; @@ -310,8 +350,7 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> { veprintln!(" APK entry: {}", path_str); - // Look for the kernel file: boot/vmlinuz-virt - if path_str == "boot/vmlinuz-virt" || path_str == "./boot/vmlinuz-virt" { + if path_str == kernel_name || path_str == format!("./{}", kernel_name) { // Extract to destination entry.unpack(dest).context("Failed to extract kernel")?; veprintln!(" Extracted kernel to: {}", dest.display()); @@ -319,7 +358,37 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> { } } - Err(anyhow!("vmlinuz-virt not found in APK package")) + Err(anyhow!("{} not found in APK package", kernel_name)) +} + +/// Decompress the cached kernel if it is gzip-compressed, in place. +/// Alpine packages the kernel image gzipped on some architectures (the +/// riscv64 and aarch64 vmlinuz are Image.gz), but QEMU's riscv `-kernel` +/// loader understands only ELF, uImage and raw images — a gzipped Image +/// is loaded verbatim and never boots. Uncompressed formats (x86 bzImage, +/// ppc64le ELF, raw Image) are left untouched. +fn decompress_kernel_if_gzipped(path: &Path) -> Result<()> { + let mut file = + std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + + let mut magic = [0u8; 2]; + let len = file + .read(&mut magic) + .context("Failed to read kernel magic bytes")?; + if len < magic.len() || magic != [0x1f, 0x8b] { + return Ok(()); + } + + veprintln!("Kernel is gzip-compressed, decompressing..."); + file.rewind().context("Failed to rewind kernel file")?; + let mut decompressed = Vec::new(); + flate2::read::MultiGzDecoder::new(file) + .read_to_end(&mut decompressed) + .context("Failed to decompress kernel")?; + + std::fs::write(path, &decompressed) + .with_context(|| format!("Failed to write decompressed kernel to {}", path.display()))?; + Ok(()) } /// Get the path to the cached default kernel for the given architecture. @@ -365,4 +434,34 @@ mod tests { assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le"); assert_eq!(alpine_kernel_arch("s390x"), "s390x"); } + + #[test] + fn test_decompress_kernel_if_gzipped() { + use flate2::write::GzEncoder; + use std::io::Write; + + // A gzip-compressed kernel is decompressed in place + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("kernel"); + let mut encoder = GzEncoder::new( + std::fs::File::create(&path).unwrap(), + flate2::Compression::default(), + ); + encoder.write_all(b"fake kernel image").unwrap(); + encoder.finish().unwrap(); + + decompress_kernel_if_gzipped(&path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"fake kernel image"); + } + + #[test] + fn test_decompress_leaves_plain_kernel_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("kernel"); + // An ELF-ish header that is not the gzip magic + std::fs::write(&path, b"\x7fELFfake kernel image").unwrap(); + + decompress_kernel_if_gzipped(&path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"\x7fELFfake kernel image"); + } }