feat: download alpine linux-virt kernel for single --kernel

This commit is contained in:
2026-06-19 16:46:23 +02:00
parent 1d2031b3ca
commit 3188566b6e
6 changed files with 413 additions and 26 deletions
+3 -3
View File
@@ -170,9 +170,9 @@ mod tests {
assert_eq!(env.len(), 5);
// Should NOT have any host-specific variables
assert!(env.get("LANG").is_none());
assert!(env.get("DISPLAY").is_none());
assert!(env.get("PWD").is_none());
assert!(!env.contains_key("LANG"));
assert!(!env.contains_key("DISPLAY"));
assert!(!env.contains_key("PWD"));
}
#[test]
+7 -3
View File
@@ -33,9 +33,13 @@ pub struct Args {
#[arg(short = 'v', long)]
pub verbose: bool,
/// Boot with QEMU system emulation using specified kernel (extracts rootfs as disk image)
#[arg(long, value_name = "KERNEL_PATH")]
pub kernel: Option<PathBuf>,
/// Boot with QEMU system emulation (optionally specify kernel path, or omit to download default)
///
/// Examples:
/// --kernel Download and use the default Alpine linux-virt kernel
/// --kernel ./vmlinuz Use a specific kernel file
#[arg(long, value_name = "KERNEL_PATH", num_args = 0..=1)]
pub kernel: Option<Option<PathBuf>>,
/// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G)
#[arg(short = 'm', long, default_value = "2G", value_name = "SIZE")]
+368
View File
@@ -0,0 +1,368 @@
//! 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:
//!
//! - Small size (~10-15MB compressed)
//! - VM-optimized configuration
//! - Multi-architecture support
//! - Simple direct download URLs
//!
//! The kernel is cached in the same cache directory as rootfs images.
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Alpine architecture mapping for kernel packages
fn alpine_kernel_arch(arch: &str) -> &'static str {
match arch {
"amd64" | "x86_64" => "x86_64",
"arm64" | "aarch64" => "aarch64",
"armhf" | "armv7l" | "arm" => "armv7",
"riscv64" => "riscv64",
"ppc64le" => "ppc64le",
"s390x" => "s390x",
"x86" | "i386" | "i686" => "x86",
_ => "x86_64",
}
}
/// Get the Alpine version branch for kernel downloads
/// We use the latest stable branch
fn get_alpine_branch() -> Result<String> {
// Fetch the latest-stable branch from Alpine CDN
// The URL redirects to the current stable version
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
let response = client
.head("https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/")
.send()
.context("Failed to check Alpine latest-stable")?;
// The final URL after redirect contains the version, e.g.:
// https://dl-cdn.alpinelinux.org/alpine/v3.23/main/
if let Some(final_url) = response
.url()
.as_str()
.strip_prefix("https://dl-cdn.alpinelinux.org/alpine/")
{
if let Some(branch) = final_url.split('/').next() {
return Ok(branch.to_string());
}
}
// Fallback: parse from the releases YAML
fetch_alpine_branch_from_yaml()
}
fn fetch_alpine_branch_from_yaml() -> Result<String> {
#[derive(serde::Deserialize)]
struct AlpineRelease {
version: Option<String>,
}
let url =
"https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/latest-releases.yaml";
let text = reqwest::blocking::get(url)
.context("Failed to fetch Alpine latest-releases.yaml")?
.text()
.context("Failed to read Alpine latest-releases.yaml")?;
let releases: Vec<AlpineRelease> =
serde_yaml::from_str(&text).context("Failed to parse Alpine latest-releases.yaml")?;
if let Some(release) = releases.first() {
if let Some(version) = &release.version {
// version is like "3.23.0", we want "v3.23"
let parts: Vec<&str> = version.split('.').collect();
if parts.len() >= 2 {
return Ok(format!("v{}.{}", parts[0], parts[1]));
}
}
}
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> {
// Alpine package index URL
let url = format!(
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz",
branch, arch
);
veprintln!("Fetching package index: {}", url);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(&url)
.send()
.context("Failed to fetch Alpine APKINDEX")?;
if !response.status().is_success() {
return Err(anyhow!(
"Failed to fetch APKINDEX: HTTP {}",
response.status()
));
}
let bytes = response.bytes().context("Failed to read APKINDEX")?;
// Extract APKINDEX from the tar.gz
// We need to own the bytes to avoid lifetime issues
let bytes_owned = bytes.to_vec();
veprintln!(" Downloaded {} bytes", bytes_owned.len());
// First decompress gzip to memory, then parse tar
// Note: Alpine's APKINDEX.tar.gz uses concatenated gzip members (multi-member gzip)
// flate2::read::GzDecoder only reads the first member, so we use MultiGzDecoder
let cursor = std::io::Cursor::new(&bytes_owned);
let mut gz_decoder = flate2::read::MultiGzDecoder::new(cursor);
let mut decompressed = Vec::new();
gz_decoder
.read_to_end(&mut decompressed)
.context("Failed to decompress gzip")?;
veprintln!(" Decompressed {} bytes", decompressed.len());
let tar_cursor = std::io::Cursor::new(decompressed);
let mut archive = tar::Archive::new(tar_cursor);
// Iterate through entries directly
let entries_iter = archive
.entries()
.context("Failed to read APKINDEX tar entries")?;
let mut entry_count = 0;
for entry_result in entries_iter {
entry_count += 1;
let mut entry = match entry_result {
Ok(e) => e,
Err(e) => {
veprintln!(
" Warning: failed to read tar entry #{}: {}",
entry_count,
e
);
continue;
}
};
let path = entry.path().context("Failed to get entry path")?;
let path_str = path.to_string_lossy();
veprintln!(" Entry #{}: {}", entry_count, path_str);
if path_str == "APKINDEX" {
let mut contents = String::new();
entry
.read_to_string(&mut contents)
.context("Failed to read APKINDEX contents")?;
veprintln!(" APKINDEX size: {} bytes", contents.len());
// Parse the APKINDEX to find linux-virt
// Format:
// P:linux-virt
// V:6.12.8-r0
// ...
let mut pkg_name: Option<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 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."));
}
}
veprintln!(" Total entries processed: {}", entry_count);
Err(anyhow!(
"APKINDEX file not found in tar.gz archive (processed {} entries)",
entry_count
))
}
/// Download and extract the linux-virt kernel from Alpine's 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);
// Construct the download URL for the linux-virt .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
);
veprintln!("Downloading kernel: {}", url);
// Use async download via the existing download module pattern
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
let temp_apk = dest.with_extension("apk");
extract_kernel_from_apk(&temp_apk, dest)?;
// Clean up the APK
std::fs::remove_file(&temp_apk).ok();
Ok(())
}
async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> {
use futures_util::StreamExt;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(url)
.send()
.await
.context("Failed to start kernel download")?;
if !response.status().is_success() {
return Err(anyhow!(
"Kernel download failed: HTTP {}",
response.status()
));
}
let total_size = response.content_length().unwrap_or(0);
// Setup progress bar
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let temp_apk = dest.with_extension("apk.partial");
let mut file = tokio::fs::File::create(&temp_apk)
.await
.context("Failed to create temp APK file")?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Failed to read chunk")?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.context("Failed to write chunk")?;
downloaded += chunk.len() as u64;
pb.set_position(downloaded);
}
tokio::io::AsyncWriteExt::flush(&mut file)
.await
.context("Failed to flush file")?;
pb.finish_with_message("Download complete");
// Rename to final name
let final_apk = dest.with_extension("apk");
tokio::fs::rename(&temp_apk, &final_apk)
.await
.context("Failed to rename temp file")?;
Ok(())
}
/// Extract vmlinuz-virt from an Alpine APK file
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> {
veprintln!("Extracting kernel from APK...");
let file = std::fs::File::open(apk_path).context("Failed to open APK file")?;
// Use MultiGzDecoder because Alpine APKs have concatenated gzip members
let gz_decoder = flate2::read::MultiGzDecoder::new(file);
let mut archive = tar::Archive::new(gz_decoder);
for entry in archive.entries().context("Failed to read APK entries")? {
let mut entry = entry.context("Failed to read tar entry")?;
let path = entry.path().context("Failed to get entry path")?;
let path_str = path.to_string_lossy();
veprintln!(" APK entry: {}", path_str);
// Look for the kernel file: boot/vmlinuz-virt
if path_str == "boot/vmlinuz-virt" || path_str == "./boot/vmlinuz-virt" {
// Extract to destination
entry.unpack(dest).context("Failed to extract kernel")?;
veprintln!(" Extracted kernel to: {}", dest.display());
}
}
Err(anyhow!("vmlinuz-virt not found in APK package"))
}
/// Get the path to the cached default kernel for the given architecture.
/// Downloads and caches it if not present.
pub fn get_default_kernel(cache_dir: &Path, arch: &str) -> Result<PathBuf> {
let alpine_arch = alpine_kernel_arch(arch);
// Cache filename includes architecture
let kernel_filename = format!("ecr-default-kernel-{}.vmlinuz", alpine_arch);
let kernel_path = cache_dir.join(&kernel_filename);
// Check if already cached
if kernel_path.exists() {
veprintln!("Using cached default kernel: {}", kernel_path.display());
return Ok(kernel_path);
}
// Create cache directory if needed
std::fs::create_dir_all(cache_dir).context("Failed to create cache directory")?;
// Determine Alpine branch
let branch = get_alpine_branch()?;
veprintln!("Using Alpine branch: {}", branch);
// Download and extract the kernel
download_alpine_kernel(&branch, alpine_arch, &kernel_path)?;
Ok(kernel_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alpine_kernel_arch() {
assert_eq!(alpine_kernel_arch("amd64"), "x86_64");
assert_eq!(alpine_kernel_arch("x86_64"), "x86_64");
assert_eq!(alpine_kernel_arch("arm64"), "aarch64");
assert_eq!(alpine_kernel_arch("aarch64"), "aarch64");
assert_eq!(alpine_kernel_arch("armhf"), "armv7");
assert_eq!(alpine_kernel_arch("riscv64"), "riscv64");
assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le");
assert_eq!(alpine_kernel_arch("s390x"), "s390x");
}
}
+17 -3
View File
@@ -4,6 +4,7 @@ mod config;
mod distro;
mod download;
mod extract;
mod kernel;
mod mount;
mod namespace;
mod qemu;
@@ -129,9 +130,22 @@ fn main() -> Result<()> {
extract_tarball(&cache_path, &rootfs)?;
// Branch based on --kernel flag
if let Some(kernel_path) = &args.kernel {
// Option<Option<PathBuf>>:
// None -> --kernel not specified, use namespace mode
// Some(None) -> --kernel without path, download default kernel
// Some(Some(path)) -> --kernel /path/to/vmlinuz, use provided kernel
if let Some(kernel_opt) = &args.kernel {
// QEMU system mode
veprintln!("QEMU mode: booting with kernel {}", kernel_path.display());
let kernel_path = match kernel_opt {
Some(path) => {
veprintln!("QEMU mode: using provided kernel {}", path.display());
path.clone()
}
None => {
veprintln!("QEMU mode: downloading default kernel...");
kernel::get_default_kernel(&cache_dir, &arch)?
}
};
let command = if args.command.is_empty() {
None
@@ -140,7 +154,7 @@ fn main() -> Result<()> {
};
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
kernel_path: kernel_path.clone(),
kernel_path,
rootfs_path: rootfs,
memory: args.memory.clone(),
arch: arch.clone(),
+4 -11
View File
@@ -250,10 +250,10 @@ mod tests {
}
#[test]
fn test_arch_canonical_name() {
assert_eq!(Arch::Amd64.canonical_name(), "x86_64");
assert_eq!(Arch::Arm64.canonical_name(), "aarch64");
assert_eq!(Arch::Armhf.canonical_name(), "armv7l");
fn test_arch_alpine_name() {
assert_eq!(Arch::Amd64.alpine_name(), "x86_64");
assert_eq!(Arch::Arm64.alpine_name(), "aarch64");
assert_eq!(Arch::Armhf.alpine_name(), "armv7");
}
#[test]
@@ -263,13 +263,6 @@ mod tests {
assert_eq!(Arch::Armhf.oci_name(), "arm");
}
#[test]
fn test_arch_alpine_name() {
assert_eq!(Arch::Amd64.alpine_name(), "x86_64");
assert_eq!(Arch::Arm64.alpine_name(), "aarch64");
assert_eq!(Arch::Armhf.alpine_name(), "armv7");
}
#[test]
fn test_validate_memory_string_valid() {
assert!(validate_memory_string("512M").is_ok());