fix(kernel): fall back to linux-lts where linux-virt is absent

Alpine does not build the linux-virt flavor for riscv64, so --kernel
without a path failed there with 'linux-virt package not found'. Scan
the APKINDEX once for both flavors and prefer linux-virt, taking
linux-lts when the virt flavor is missing.

Also store the cached kernel decompressed: riscv64 and aarch64 ship
their image gzipped (Image.gz), and QEMU's riscv -kernel loader
understands only ELF, uImage and raw images, so the gzipped image hung
at boot after the OpenSBI banner.
This commit is contained in:
2026-09-21 01:03:18 +02:00
parent de507682c1
commit 5dd9dff76a
2 changed files with 128 additions and 28 deletions
+2 -1
View File
@@ -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
+126 -27
View File
@@ -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<String> {
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<String> {
/// 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<String> {
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<String> = 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<String> {
))
}
/// 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");
}
}