From 3188566b6e981c982b6643ee49f1972101255123 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 19 Jun 2026 16:46:23 +0200 Subject: [PATCH] feat: download alpine linux-virt kernel for single --kernel --- README.md | 20 ++- src/chroot.rs | 6 +- src/cli.rs | 10 +- src/kernel.rs | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 20 ++- src/utils.rs | 15 +- 6 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 src/kernel.rs diff --git a/README.md b/README.md index 5ba6d2d..89ec53d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ecr [OPTIONS] [-- COMMAND...] | `--no-cache` | Force a fresh download, bypassing the cache | | `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) | | `-a, --arch ` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) | -| `--kernel ` | Boot with QEMU system emulation using specified kernel | +| `--kernel [PATH]` | Boot with QEMU system emulation. Downloads Alpine's `linux-virt` kernel if no path provided | | `-m, --memory ` | Memory for QEMU VM (default: 2G, only with `--kernel`) | ## Examples @@ -59,11 +59,14 @@ ecr --arch arm64 alpine -- uname -m # Always pull a fresh image ecr --no-cache fedora -# Boot with QEMU system emulation (requires qemu-system-) +# Boot with QEMU system emulation (auto-downloads default kernel) +ecr --kernel alpine + +# Boot with your own kernel ecr --kernel /boot/vmlinuz ubuntu # Boot with custom memory -ecr --kernel /boot/vmlinuz --memory 4G alpine +ecr --kernel --memory 4G alpine ``` ## QEMU System Mode @@ -71,18 +74,23 @@ ecr --kernel /boot/vmlinuz --memory 4G alpine When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces: ```sh -ecr --kernel /boot/vmlinuz alpine +# Auto-download Alpine's linux-virt kernel (recommended) +ecr --kernel alpine + +# Use your own kernel +ecr --kernel /boot/vmlinuz ubuntu ``` This mode: - Creates a gzipped CPIO initramfs from the rootfs -- Boots QEMU with your kernel +- Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel) - Provides full VM isolation - Works for any architecture (no binfmt_misc needed) +- Caches the default kernel in `~/.cache/ecr/` Requirements: - `qemu-system-` installed -- Kernel with serial console support +- For custom kernels: kernel must have serial console support ## Supported distributions diff --git a/src/chroot.rs b/src/chroot.rs index 811edfd..e406f21 100644 --- a/src/chroot.rs +++ b/src/chroot.rs @@ -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] diff --git a/src/cli.rs b/src/cli.rs index 11cf837..488054e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + /// 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>, /// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G) #[arg(short = 'm', long, default_value = "2G", value_name = "SIZE")] diff --git a/src/kernel.rs b/src/kernel.rs new file mode 100644 index 0000000..bad40fb --- /dev/null +++ b/src/kernel.rs @@ -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 { + // 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 { + #[derive(serde::Deserialize)] + struct AlpineRelease { + version: Option, + } + + 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 = + 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 { + // 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 = 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 { + 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"); + } +} diff --git a/src/main.rs b/src/main.rs index a85fc3b..60283b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>: + // 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(), diff --git a/src/utils.rs b/src/utils.rs index 5952a0d..60dbca1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -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());