feat: download alpine linux-virt kernel for single --kernel
This commit is contained in:
@@ -38,7 +38,7 @@ ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
|
||||
| `--no-cache` | Force a fresh download, bypassing the cache |
|
||||
| `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) |
|
||||
| `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) |
|
||||
| `--kernel <PATH>` | 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 <SIZE>` | 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-<arch>)
|
||||
# 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-<arch>` installed
|
||||
- Kernel with serial console support
|
||||
- For custom kernels: kernel must have serial console support
|
||||
|
||||
## Supported distributions
|
||||
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user