diff --git a/Cargo.lock b/Cargo.lock index 5cb59f0..28a9719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1403,6 +1409,20 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "vt100", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2155,6 +2175,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2221,7 +2247,7 @@ dependencies = [ [[package]] name = "qlean" -version = "0.2.3" +version = "0.3.0" dependencies = [ "anyhow", "console", @@ -2229,6 +2255,7 @@ dependencies = [ "dir-lock", "directories", "futures", + "indicatif", "kvm-ioctls", "nanoid", "rand 0.9.2", @@ -2248,6 +2275,7 @@ dependencies = [ "tokio-util", "tokio-vsock", "tracing", + "tracing-indicatif", "tracing-subscriber", "walkdir", ] @@ -3483,6 +3511,18 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-indicatif" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ef6990e0438749f0080573248e96631171a0b5ddfddde119aa5ba8c3a9c47e" +dependencies = [ + "indicatif", + "tracing", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -3537,6 +3577,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" @@ -3620,6 +3666,27 @@ dependencies = [ "nix 0.30.1", ] +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index c143927..1325bce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "qlean" -version = "0.2.3" +version = "0.3.0" authors = ["jl.jiang "] categories = ["development-tools::testing", "virtualization"] description = "A system-level isolation testing library based on QEMU/KVM." -documentation = "https://docs.rs/qlean" +documentation = "https://buck2hub.com/docs/qlean" edition = "2024" exclude = ["/.github"] keywords = ["isolation", "testing", "kvm", "qemu"] @@ -18,6 +18,7 @@ console = "0.16.2" dir-lock = "0.5.0" directories = "6.0.0" futures = "0.3" +indicatif = "0.18.4" kvm-ioctls = "0.24.0" nanoid = "0.4.0" rand = "0.9.2" @@ -35,14 +36,22 @@ tokio-fd = "0.3.0" tokio-util = "0.7.18" tokio-vsock = "0.7.2" tracing = { version = "0.1.43", features = ["log"] } +tracing-indicatif = "0.3.14" walkdir = "2.5.0" [dev-dependencies] criterion = { version = "0.5", features = ["async_tokio"] } serial_test = "3.3.1" tempfile = "3.24.0" -tracing-subscriber = { version = "0.3.22", features = ["env-filter", "local-time"] } +tracing-indicatif = "0.3.14" +tracing-subscriber = { version = "0.3.22", features = [ + "env-filter", + "local-time", +] } [[bench]] name = "hash_benchmark" harness = false + +[profile.dev.package.sha2] +opt-level = 3 diff --git a/README.md b/README.md index bf380ec..5de87c8 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,6 @@ For projects requiring multi-machine collaboration, Qlean provides a simple API - 🛡️ **RAII-style Interface**: Automatic resource management ensures VMs are properly cleaned up - 📦 **Out-of-the-Box**: Automated image downloading and extraction, no manual configuration needed - 🐧 **Linux Native**: Native support for Linux hosts with multiple Linux distributions -- 🌐 **Multi-Distro Support**: Built-in support for Debian, Ubuntu, Fedora, and Arch Linux -- 🎯 **Custom Images**: Use any Linux distribution with URL/local path + checksum verification -- ⚡ **Performance Optimized****: Streaming hash computation with 5-30% performance improvement ## Usage @@ -37,11 +34,7 @@ Before using Qlean, ensure that QEMU, guestfish, libvirt, libguestfs-tools and s qemu-system-x86_64 --version qemu-img --version virsh --version -guestfish --version -virt-copy-out --version xorriso --version -sha256sum --version -sha512sum --version ``` #### Configure qemu-bridge-helper @@ -69,7 +62,7 @@ Add the dependency to your `Cargo.toml`: ```toml [dev-dependencies] -qlean = "0.2" +qlean = "0.3" tokio = { version = "1", features = ["full"] } ``` @@ -79,12 +72,17 @@ Here's a simple test example with single machine: ```rust use anyhow::Result; -use qlean::{Distro, MachineConfig, create_image, with_machine}; +use qlean::{Distro, Image, ImageConfig, GuestArch, MachineConfig, with_machine}; #[tokio::test] async fn test_with_vm() -> Result<()> { // Create VM image and config - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new( + ImageConfig::default() + .with_arch(GuestArch::Amd64), + .with_distro(Distro::Debian) + ) + .await?; let config = MachineConfig::default(); // Execute tests in the virtual machine @@ -108,14 +106,19 @@ The following is another example of a multi-machine test: ```rust use anyhow::Result; -use qlean::{Distro, MachineConfig, create_image, with_pool}; +use qlean::{Distro, Image, ImageConfig, GuestArch, MachineConfig, create_image, with_pool}; #[tokio::test] async fn test_ping() -> Result<()> { with_pool(|pool| { Box::pin(async { // Create VM image and config - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new( + ImageConfig::default() + .with_distro(Distro::Debian) + .with_arch(GuestArch::Amd64), + ) + .await?; let config = MachineConfig::default(); // Add machines to the pool and initialize them concurrently @@ -146,246 +149,6 @@ async fn test_ping() -> Result<()> { For more examples, please refer to the [tests](tests) directory. -## Multi-Distribution Support - -Qlean provides built-in support for multiple mainstream Linux distributions. Each distribution is optimized for ease of use and compatibility. - -### Supported Distributions - -| Distribution | Status | WSL Compatible | Boot File Extraction | Version | -|--------------|--------|----------------|---------------------|---------| -| **Debian** | ✅ Stable | ⚠️ Requires guestfish | Auto (guestfish) | Debian 13 (Trixie) | -| **Ubuntu** | ✅ Stable | ✅ **Fully Compatible** | Pre-extracted | Ubuntu 24.04 LTS (Noble) | -| **Fedora** | ✅ Stable | ⚠️ Requires guestfish | Auto (guestfish) | Fedora 41 | -| **Arch** | ✅ Stable | ⚠️ Requires guestfish | Auto (guestfish) | Latest | -| **Custom** | ✅ Stable | ✅ Optional | Flexible | Any Linux distro | - -### Quick Start with Different Distributions - -**Ubuntu (Recommended for WSL users)** -```rust -use anyhow::Result; -use qlean::{Distro, create_image, MachineConfig, with_machine}; - -#[tokio::test] -async fn test_ubuntu_vm() -> Result<()> { - // Ubuntu - WSL friendly, no guestfish required - let image = create_image(Distro::Ubuntu, "ubuntu-noble-cloudimg").await?; - let config = MachineConfig::default(); - - with_machine(&image, &config, |vm| { - Box::pin(async { - let result = vm.exec("lsb_release -a").await?; - assert!(result.status.success()); - Ok(()) - }) - }) - .await?; - - Ok(()) -} -``` - -**Fedora** -```rust -#[tokio::test] -async fn test_fedora_vm() -> Result<()> { - // Fedora - requires guestfish on host - let image = create_image(Distro::Fedora, "fedora-41-cloud").await?; - let config = MachineConfig::default(); - - with_machine(&image, &config, |vm| { - Box::pin(async { - let result = vm.exec("cat /etc/fedora-release").await?; - assert!(result.status.success()); - Ok(()) - }) - }) - .await?; - - Ok(()) -} -``` - -**Arch Linux** -```rust -#[tokio::test] -async fn test_arch_vm() -> Result<()> { - // Arch - requires guestfish on host - let image = create_image(Distro::Arch, "arch-cloud").await?; - let config = MachineConfig::default(); - - with_machine(&image, &config, |vm| { - Box::pin(async { - let result = vm.exec("uname -r").await?; - assert!(result.status.success()); - Ok(()) - }) - }) - .await?; - - Ok(()) -} -``` - -> **💡 Tip**: Ubuntu is the recommended distribution for WSL users as it doesn't require guestfish for boot file extraction. - -## Custom Images - -Qlean supports using custom Linux distributions through URL downloads or local file paths, with **mandatory checksum verification** for security. - -### Features - -- 🔗 **Flexible Sources**: Download from URL or use local qcow2 files -- 🔒 **Security First**: Mandatory SHA-256/SHA-512 checksum verification -- 🪟 **WSL Compatible**: Optional pre-extracted kernel/initrd mode -- 🎯 **Any Distribution**: Support for any Linux distribution with qcow2 images - -### Two Modes - -#### Mode 1: Pre-extracted Boot Files (Recommended for WSL) - -Provide the image, kernel, and initrd files separately with checksums. **This mode works on WSL** and doesn't require guestfish. -```rust -use anyhow::Result; -use qlean::{create_custom_image, CustomImageConfig, ImageSource, ShaType}; - -#[tokio::test] -async fn test_custom_ubuntu() -> Result<()> { - let config = CustomImageConfig { - // Main qcow2 image - image_source: ImageSource::Url( - "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img".into() - ), - image_hash: "abc123...".into(), // Get from SHA256SUMS file - image_hash_type: ShaType::Sha256, - - // Pre-extracted kernel (WSL-friendly) - kernel_source: Some(ImageSource::Url( - "https://cloud-images.ubuntu.com/noble/current/unpacked/noble-server-cloudimg-amd64-vmlinuz-generic".into() - )), - kernel_hash: Some("def456...".into()), - - // Pre-extracted initrd - initrd_source: Some(ImageSource::Url( - "https://cloud-images.ubuntu.com/noble/current/unpacked/noble-server-cloudimg-amd64-initrd-generic".into() - )), - initrd_hash: Some("ghi789...".into()), - }; - - let image = create_custom_image("my-ubuntu", config).await?; - - // Use the image... - Ok(()) -} -``` - -#### Mode 2: Auto-extract Boot Files (Native Linux only) - -Provide only the image file with its checksum. Qlean will automatically extract kernel and initrd using guestfish. -```rust -#[tokio::test] -async fn test_custom_auto_extract() -> Result<()> { - let config = CustomImageConfig { - image_source: ImageSource::Url( - "https://example.com/my-distro.qcow2".into() - ), - image_hash: "your-sha256-hash".into(), - image_hash_type: ShaType::Sha256, - - // No kernel/initrd - will auto-extract - kernel_source: None, - kernel_hash: None, - initrd_source: None, - initrd_hash: None, - }; - - let image = create_custom_image("my-distro", config).await?; - Ok(()) -} -``` - -### Using Local Files - -You can also use local qcow2 images: -```rust -use std::path::PathBuf; - -#[tokio::test] -async fn test_local_custom_image() -> Result<()> { - let config = CustomImageConfig { - image_source: ImageSource::LocalPath( - PathBuf::from("/path/to/my-image.qcow2") - ), - image_hash: "your-hash".into(), - image_hash_type: ShaType::Sha256, - - kernel_source: Some(ImageSource::LocalPath( - PathBuf::from("/path/to/vmlinuz") - )), - kernel_hash: Some("kernel-hash".into()), - - initrd_source: Some(ImageSource::LocalPath( - PathBuf::from("/path/to/initrd.img") - )), - initrd_hash: Some("initrd-hash".into()), - }; - - let image = create_custom_image("local-distro", config).await?; - Ok(()) -} -``` - -### How to Get Checksums - -**For Ubuntu cloud images:** -```bash -# 1. Visit Ubuntu cloud images -# https://cloud-images.ubuntu.com/noble/current/ - -# 2. Download SHA256SUMS file -wget https://cloud-images.ubuntu.com/noble/current/SHA256SUMS - -# 3. Find checksums for your files -grep "noble-server-cloudimg-amd64.img" SHA256SUMS -grep "vmlinuz-generic" SHA256SUMS -grep "initrd-generic" SHA256SUMS -``` - -**For other distributions:** -- **Fedora**: Check the CHECKSUM file in the release directory -- **Arch**: Look for `.SHA256` files alongside the image -- **Custom images**: Compute using `sha256sum your-file.qcow2` or `sha512sum your-file.qcow2` - -### Security - -**All custom images require checksum verification.** This ensures: - -- ✅ Protection against corrupted downloads -- ✅ Protection against man-in-the-middle attacks -- ✅ Verification of file integrity - -If the checksum doesn't match, image creation will fail with an error. - -### Common Errors - -**Error: "guestfish not available"** - -This error occurs when using auto-extraction mode (Mode 2) on WSL or without guestfish installed. - -**Solution:** -- Use Mode 1 (pre-extracted boot files) for WSL compatibility, or -- Install libguestfs-tools on native Linux: `sudo apt install libguestfs-tools` - -**Error: "hash mismatch"** - -This indicates the file doesn't match the expected checksum. - -**Solution:** -- Verify you copied the correct hash from the official source -- Re-download the file (might be corrupted) -- Check you're using the correct hash type (SHA256 vs SHA512) - ## Network Configuration Qlean uses a dedicated libvirt virtual network to provide isolated, reproducible networking for test VMs. The default network definition is stored at `~/.local/share/qlean/network.xml` as follows: @@ -412,54 +175,9 @@ This configuration defines a **NAT-based** virtual network named `qlean` (used i ### Top-Level Interface -**create_image(distro, name)** - Create or retrieve a VM image from the specified distribution - -Supported distributions: -- `Distro::Debian` - Debian 13 (Trixie) -- `Distro::Ubuntu` - Ubuntu 24.04 LTS (Noble) - **WSL friendly** -- `Distro::Fedora` - Fedora 41 -- `Distro::Arch` - Arch Linux (latest) -```rust -pub async fn create_image(distro: Distro, name: &str) -> Result -``` - -**create_custom_image(name, config)** - Create a custom image with flexible configuration -```rust -pub async fn create_custom_image( - name: &str, - config: CustomImageConfig -) -> Result -``` - -Configuration types: -```rust -pub struct CustomImageConfig { - // Image file (required) - pub image_source: ImageSource, - pub image_hash: String, - pub image_hash_type: ShaType, - - // Optional: pre-extracted kernel and initrd (WSL-friendly) - pub kernel_source: Option, - pub kernel_hash: Option, - pub initrd_source: Option, - pub initrd_hash: Option, -} - -pub enum ImageSource { - Url(String), // Download from URL - LocalPath(PathBuf), // Use local file -} - -pub enum ShaType { - Sha256, // SHA-256 checksum - Sha512, // SHA-512 checksum -} -``` - -**with_machine(image, config, f)** - Execute an async closure in a virtual machine with automatic resource cleanup - -**with_pool(f)** - Execute an async closure in a machine pool with automatic resource cleanup +- `is_kvm_available()` - Check if KVM is available on host. +- `with_machine(image, config, f)` - Execute an async closure in a virtual machine with automatic resource cleanup +- `with_pool(f)` - Execute an async closure in a machine pool with automatic resource cleanup - `MachineConfig` - Configuration for virtual machine resources (CPU, memory, disk) ```rust diff --git a/src/image.rs b/src/image.rs index e4f75d3..937ae30 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,147 +1,378 @@ -use std::path::{Path, PathBuf}; +use std::{ + io::{BufReader, BufWriter, Read, Write}, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result, bail}; use futures::StreamExt; +use indicatif::{ProgressState, ProgressStyle}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha512}; use tokio::{fs::File, io::AsyncWriteExt}; -use tracing::debug; - -use crate::utils::QleanDirs; - -pub trait ImageAction { - /// Download the image from remote source - fn download(&self, name: &str) -> impl std::future::Future> + Send; - /// Extract kernel and initrd from the image - fn extract( - &self, - name: &str, - ) -> impl std::future::Future> + Send; - /// Get the distro type - fn distro(&self) -> Distro; -} +use tracing::{Span, info, info_span}; +use tracing_indicatif::span_ext::IndicatifSpanExt; + +use crate::utils::{QleanDirs, qlean_user_agent}; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageMeta { +pub struct Image { pub name: String, pub path: PathBuf, - pub kernel: PathBuf, - pub initrd: PathBuf, - #[serde(skip)] - pub vendor: A, - pub checksum: ShaSum, + pub arch: GuestArch, + pub distro: Distro, + pub digest: (ShaType, String), } -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Default)] pub enum Distro { + #[default] Debian, Ubuntu, Fedora, Arch, - Custom, } -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Default)] +pub enum GuestArch { + #[default] + Amd64, + Aarch64, + Riscv64, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] pub enum ShaType { Sha256, Sha512, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShaSum { - pub path: PathBuf, - pub sha_type: ShaType, -} - /// Source of a file: URL or local file path -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ImageSource { Url(String), LocalPath(PathBuf), } -/// Configuration for custom images - supports two modes: -/// 1. Image only (requires guestfish for extraction) -/// 2. Image + pre-extracted kernel/initrd (WSL-friendly) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CustomImageConfig { - // Image file (required) - pub image_source: ImageSource, - pub image_hash: String, - pub image_hash_type: ShaType, - - // Optional: pre-extracted kernel and initrd (for WSL compatibility) - pub kernel_source: Option, - pub kernel_hash: Option, - pub initrd_source: Option, - pub initrd_hash: Option, +impl Default for ImageSource { + fn default() -> Self { + ImageSource::Url(String::new()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ImageConfig { + pub arch: GuestArch, + pub distro: Distro, + pub source: Option, + pub digest: Option, +} + +impl ImageConfig { + pub fn with_distro(self, distro: Distro) -> Self { + Self { distro, ..self } + } + + pub fn with_arch(self, arch: GuestArch) -> Self { + Self { arch, ..self } + } + + pub fn with_source(self, source: String) -> Self { + Self { + source: Some(source), + ..self + } + } + + pub fn with_digest(self, digest: String) -> Self { + Self { + digest: Some(digest), + ..self + } + } + + /// `source` and `digest` must both be set or both omitted. + pub fn validate(&self) -> Result<()> { + anyhow::ensure!( + (self.source.is_none() && self.digest.is_none()) + || (self.source.is_some() && self.digest.is_some()), + "source and digest must both be set or both omitted" + ); + Ok(()) + } +} + +impl AsRef for ImageConfig { + fn as_ref(&self) -> &ImageConfig { + self + } +} + +fn image_cache_name( + distro: Distro, + arch: GuestArch, + override_source: &Option, +) -> Result { + if let Some(src) = override_source { + return Ok(cache_name_from_source(src)); + } + let spec = builtin_remote_image(distro, arch)?; + Ok(stem_from_filename(spec.checksum_entry)) +} + +/// Normalize checksum entry names across common checksum file formats. +fn normalize_checksum_name(name: &str) -> &str { + name.trim_start_matches('*').trim_start_matches("./") +} + +fn checksum_name_matches(entry_name: &str, wanted: &str) -> bool { + let entry = normalize_checksum_name(entry_name); + let wanted = normalize_checksum_name(wanted); + if entry == wanted { + return true; + } + if !wanted.contains('/') + && let Some(base) = entry.rsplit('/').next() + { + return base == wanted; + } + false +} + +/// Strip PGP armor wrapper (e.g. Fedora `*-CHECKSUM`) so line-oriented parsers see only the payload. +fn checksum_text_payload(raw: &str) -> &str { + let marker = "-----BEGIN PGP SIGNED MESSAGE-----"; + let Some(idx) = raw.find(marker) else { + return raw; + }; + let after = &raw[idx + marker.len()..]; + let body_start = after.find("\n\n").map(|i| i + 2).unwrap_or(0); + let mut body = &after[body_start..]; + if let Some(sig) = body.find("-----BEGIN PGP SIGNATURE-----") { + body = &body[..sig]; + } + body } -/// Parses SHA512SUMS format and returns the hash for an exact filename match. +/// Parse a checksum file and return the hash for a given filename. /// -/// # Arguments -/// * `checksums_text` - The content of a SHA512SUMS file -/// * `filename` - The exact filename to search for (e.g., "debian-13-generic-amd64.qcow2") +/// Supports common formats: +/// 1) " " (including "*filename" and "./filename"), one pair per line +/// 2) "SHA256 () = " / "SHA512 () = " /// -/// # Returns -/// The SHA512 hash if found, or None if no exact match exists -pub fn find_sha512_for_file(checksums_text: &str, filename: &str) -> Option { - checksums_text.lines().find_map(|line| { +/// Comment lines (`#` ...) and PGP-signed-message wrappers are ignored. +pub fn find_hash_for_file(checksums_text: &str, filename: &str) -> Option { + let payload = checksum_text_payload(checksums_text); + + for line in payload.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } let mut parts = line.split_whitespace(); - let hash = parts.next()?; - let fname = parts.next()?; + let Some(first) = parts.next() else { continue }; + // Format: SHA256 (name) = hex + for prefix in ["SHA256 (", "SHA512 ("] { + if let Some(rest) = line.strip_prefix(prefix) + && let Some((entry_name, hash_part)) = rest.split_once(") = ") + && checksum_name_matches(entry_name, filename) + { + return Some(hash_part.trim().to_string()); + } + } + let Some(second) = parts.next() else { continue }; + if first.starts_with("SHA256(") || first.starts_with("SHA512(") { + continue; + } + if checksum_name_matches(second, filename) { + return Some(first.to_string()); + } + } + + None +} + +/// Built-in official cloud image locations (pin versions by editing this module). +#[derive(Debug, Clone, PartialEq, Eq)] +struct RemoteImageSpec { + image_url: &'static str, + checksum_url: &'static str, + checksum_entry: &'static str, + checksum_type: ShaType, +} + +fn builtin_remote_image(distro: Distro, arch: GuestArch) -> Result { + match arch { + GuestArch::Amd64 => match distro { + Distro::Debian => Ok(RemoteImageSpec { + image_url: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2", + checksum_url: "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", + checksum_entry: "debian-13-generic-amd64.qcow2", + checksum_type: ShaType::Sha512, + }), + Distro::Ubuntu => Ok(RemoteImageSpec { + image_url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", + checksum_url: "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", + checksum_entry: "noble-server-cloudimg-amd64.img", + checksum_type: ShaType::Sha256, + }), + Distro::Fedora => Ok(RemoteImageSpec { + image_url: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2", + checksum_url: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-43-1.6-x86_64-CHECKSUM", + checksum_entry: "Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2", + checksum_type: ShaType::Sha256, + }), + Distro::Arch => Ok(RemoteImageSpec { + image_url: "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2", + checksum_url: "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2.SHA256", + checksum_entry: "Arch-Linux-x86_64-cloudimg.qcow2", + checksum_type: ShaType::Sha256, + }), + }, + GuestArch::Aarch64 => { + bail!( + "builtin image specs are currently only available for amd64, got {:?}", + arch + ); + } + GuestArch::Riscv64 => { + bail!( + "builtin image specs are currently only available for amd64, got {:?}", + arch + ); + } + } +} + +async fn fetch_text(url: &str) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(30)) + .user_agent(qlean_user_agent()) + .build() + .with_context(|| "failed to build HTTP client")?; + + let resp = client + .get(url) + .send() + .await + .with_context(|| format!("failed to GET {}", url))?; + let status = resp.status(); + anyhow::ensure!(status.is_success(), "GET {} failed: {}", url, status); - (fname == filename).then(|| hash.to_string()) + resp.text() + .await + .with_context(|| format!("failed reading body from {}", url)) +} + +async fn fetch_expected_hash(spec: &RemoteImageSpec) -> Result { + let checksums_text = fetch_text(spec.checksum_url) + .await + .with_context(|| format!("failed to fetch checksum file from {}", spec.checksum_url))?; + + find_hash_for_file(&checksums_text, spec.checksum_entry).with_context(|| { + format!( + "checksum file {} did not contain an entry for {}", + spec.checksum_url, spec.checksum_entry + ) }) } -// --------------------------------------------------------------------------- -// Streaming hash functions - optimized for release mode performance -// --------------------------------------------------------------------------- +fn stem_from_filename(name: &str) -> String { + Path::new(name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(name) + .to_string() +} -/// Compute SHA-256 hash using streaming approach with sync I/O -/// This provides 7-27% better performance than shell commands in release mode -pub async fn compute_sha256_streaming(path: &Path) -> Result { - let path = path.to_path_buf(); +fn parse_prefixed_digest(s: &str) -> Result<(ShaType, String)> { + let (algo, hex) = s + .split_once(':') + .with_context(|| "digest must be in the form sha256: or sha512:")?; + let sha_type = match algo.trim().to_ascii_lowercase().as_str() { + "sha256" => ShaType::Sha256, + "sha512" => ShaType::Sha512, + _ => bail!("unsupported digest algorithm prefix: {}", algo), + }; + let body = hex.trim(); + anyhow::ensure!(!body.is_empty(), "digest body cannot be empty"); + anyhow::ensure!( + body.chars().all(|c| c.is_ascii_hexdigit()), + "digest body must be hexadecimal" + ); + Ok((sha_type, body.to_string())) +} - tokio::task::spawn_blocking(move || { - use std::io::Read; +fn resolve_image_source(source: &str) -> ImageSource { + let s = source.trim(); + let lower = s.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://") + { + ImageSource::Url(s.to_string()) + } else { + ImageSource::LocalPath(PathBuf::from(s)) + } +} - let mut file = std::fs::File::open(&path) - .with_context(|| format!("failed to open file for hashing: {}", path.display()))?; +fn cache_name_from_source(source: &ImageSource) -> String { + match source { + ImageSource::Url(url) => { + let last = url.rsplit('/').next().unwrap_or("custom-image.qcow2"); + stem_from_filename(last) + } + ImageSource::LocalPath(path) => { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("custom-image.qcow2"); + stem_from_filename(name) + } + } +} - let mut hasher = Sha256::new(); - let mut buf = vec![0u8; 64 * 1024]; // 64 KB buffer +enum StreamingHasher { + Sha256(Sha256), + Sha512(Sha512), +} - loop { - let n = file - .read(&mut buf) - .with_context(|| "failed to read file during hashing")?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); +impl StreamingHasher { + fn new(kind: &ShaType) -> Self { + match kind { + ShaType::Sha256 => Self::Sha256(Sha256::new()), + ShaType::Sha512 => Self::Sha512(Sha512::new()), } + } - Ok(format!("{:x}", hasher.finalize())) - }) - .await - .with_context(|| "hash computation task failed")? + fn update(&mut self, data: &[u8]) { + match self { + Self::Sha256(h) => h.update(data), + Self::Sha512(h) => h.update(data), + } + } + + fn finalize_hex(self) -> String { + match self { + Self::Sha256(h) => format!("{:x}", h.finalize()), + Self::Sha512(h) => format!("{:x}", h.finalize()), + } + } } -/// Compute SHA-512 hash using streaming approach with sync I/O -pub async fn compute_sha512_streaming(path: &Path) -> Result { +/// Compute a streaming hash over `path` using sync I/O on a blocking thread. +pub async fn compute_hash(path: &Path, hash_type: ShaType) -> Result { let path = path.to_path_buf(); tokio::task::spawn_blocking(move || { use std::io::Read; - let mut file = std::fs::File::open(&path) - .with_context(|| format!("failed to open file for hashing: {}", path.display()))?; + let mut file = BufReader::with_capacity( + 1024 * 1024, + std::fs::File::open(&path) + .with_context(|| format!("failed to open {}", path.display()))?, + ); - let mut hasher = Sha512::new(); let mut buf = vec![0u8; 64 * 1024]; - + let mut hasher = StreamingHasher::new(&hash_type); loop { let n = file .read(&mut buf) @@ -152,61 +383,146 @@ pub async fn compute_sha512_streaming(path: &Path) -> Result { hasher.update(&buf[..n]); } - Ok(format!("{:x}", hasher.finalize())) + Ok(hasher.finalize_hex()) }) .await .with_context(|| "hash computation task failed")? } -/// Download file and compute hash in single pass to avoid reading file twice -pub async fn download_with_hash( - url: &str, - dest_path: &PathBuf, - hash_type: ShaType, -) -> Result { - debug!("Downloading {} to {}", url, dest_path.display()); +/// Copy a file to `dest` while computing a streaming hash using buffered sync I/O. +fn copy_with_hash(src: &Path, dest: &Path, hash_type: &ShaType) -> Result { + let mut src_f = BufReader::with_capacity( + 1024 * 1024, + std::fs::File::open(src).with_context(|| format!("failed to open {}", src.display()))?, + ); + let mut dst_f = BufWriter::with_capacity( + 1024 * 1024, + std::fs::File::create(dest) + .with_context(|| format!("failed to create {}", dest.display()))?, + ); + + let mut buf = vec![0u8; 64 * 1024]; + let mut hasher = StreamingHasher::new(hash_type); + loop { + let n = src_f + .read(&mut buf) + .with_context(|| format!("failed to read {}", src.display()))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + dst_f + .write_all(&buf[..n]) + .with_context(|| format!("failed to write {}", dest.display()))?; + } + dst_f + .flush() + .with_context(|| format!("failed to flush {}", dest.display()))?; + + Ok(hasher.finalize_hex()) +} + +/// Download a remote file and compute its hash streamingly in a single pass. +async fn download_with_hash(url: &str, dest_path: &PathBuf, hash_type: ShaType) -> Result { + let tmp_path = dest_path.with_extension("part"); - let response = reqwest::get(url) + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(20)) + .user_agent(qlean_user_agent()) + .build() + .with_context(|| "failed to build HTTP client")?; + + info!("Downloading image from {}", url); + let response = tokio::time::timeout(std::time::Duration::from_secs(30), client.get(url).send()) .await + .with_context(|| format!("timed out before response headers from {}", url))? .with_context(|| format!("failed to download from {}", url))?; - let mut file = File::create(dest_path) + let status = response.status(); + let total_size = response.content_length(); + anyhow::ensure!(status.is_success(), "GET {} failed: {}", url, status); + + if let Some(parent) = tmp_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create dir {}", parent.display()))?; + } + + let _ = tokio::fs::remove_file(&tmp_path).await; + + let mut file = File::create(&tmp_path) .await - .with_context(|| format!("failed to create file at {}", dest_path.display()))?; + .with_context(|| format!("failed to create file at {}", tmp_path.display()))?; let mut stream = response.bytes_stream(); + let idle = std::time::Duration::from_secs(60); + let mut downloaded: u64 = 0; - let hash = match hash_type { - ShaType::Sha256 => { - let mut h = Sha256::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.with_context(|| "failed to read chunk")?; - h.update(&chunk); - file.write_all(&chunk) - .await - .with_context(|| "failed to write chunk")?; - } - format!("{:x}", h.finalize()) - } - ShaType::Sha512 => { - let mut h = Sha512::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.with_context(|| "failed to read chunk")?; - h.update(&chunk); - file.write_all(&chunk) - .await - .with_context(|| "failed to write chunk")?; - } - format!("{:x}", h.finalize()) - } - }; + let download_span = info_span!("http_download", url = %url); + + let style_known = ProgressStyle::with_template( + "{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", + ) + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| { + let _ = std::fmt::write(w, format_args!("{:.1}s", state.eta().as_secs_f64())); + }) + .progress_chars("#>-"); + + let style_unknown = ProgressStyle::with_template( + "{spinner:.green} [{elapsed_precise}] {bytes} ({bytes_per_sec}) {msg}", + ) + .unwrap(); + + if let Some(total) = total_size { + download_span.pb_set_style(&style_known); + download_span.pb_set_length(total); + } else { + download_span.pb_set_style(&style_unknown); + download_span.pb_set_length(u64::MAX); + } + download_span.pb_set_message("downloading"); + + let _download_enter = download_span.enter(); + + let mut hasher = StreamingHasher::new(&hash_type); + loop { + let next = tokio::time::timeout(idle, stream.next()) + .await + .with_context(|| format!("download stalled for {} (>{:?} without data)", url, idle))?; + let Some(chunk) = next else { break }; + let chunk = chunk.with_context(|| "failed to read chunk")?; + downloaded += chunk.len() as u64; + Span::current().pb_set_position(downloaded); + hasher.update(&chunk); + file.write_all(&chunk) + .await + .with_context(|| "failed to write chunk")?; + } + + let hash = hasher.finalize_hex(); + + download_span.pb_set_finish_message(&format!("{} MiB downloaded", downloaded / (1024 * 1024))); + std::mem::drop(_download_enter); file.flush().await.with_context(|| "failed to flush file")?; + + tokio::fs::rename(&tmp_path, dest_path) + .await + .with_context(|| { + format!( + "failed to move {} -> {}", + tmp_path.display(), + dest_path.display() + ) + })?; + + info!("Download completed"); Ok(hash) } -/// Download or copy file from ImageSource with hash verification -async fn download_or_copy_with_hash( +/// Fetch image file from remote source or local path and verify it against the expected hash. +async fn fetch_from_source( source: &ImageSource, dest: &PathBuf, expected_hash: &str, @@ -214,9 +530,9 @@ async fn download_or_copy_with_hash( ) -> Result<()> { match source { ImageSource::Url(url) => { - let computed = download_with_hash(url, dest, hash_type).await?; + let computed = download_with_hash(url, dest, hash_type.clone()).await?; anyhow::ensure!( - computed.to_lowercase() == expected_hash.to_lowercase(), + computed.eq_ignore_ascii_case(expected_hash), "hash mismatch: expected {}, got {}", expected_hash, computed @@ -224,15 +540,22 @@ async fn download_or_copy_with_hash( } ImageSource::LocalPath(src) => { anyhow::ensure!(src.exists(), "file does not exist: {}", src.display()); - tokio::fs::copy(src, dest).await?; + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create dir {}", parent.display()))?; + } - let computed = match hash_type { - ShaType::Sha256 => compute_sha256_streaming(dest).await?, - ShaType::Sha512 => compute_sha512_streaming(dest).await?, - }; + let src_path = src.clone(); + let dest_path = dest.clone(); + let computed = tokio::task::spawn_blocking(move || { + copy_with_hash(&src_path, &dest_path, &hash_type) + }) + .await + .with_context(|| "copy/hash task failed")??; anyhow::ensure!( - computed.to_lowercase() == expected_hash.to_lowercase(), + computed.eq_ignore_ascii_case(expected_hash), "hash mismatch: expected {}, got {}", expected_hash, computed @@ -242,45 +565,71 @@ async fn download_or_copy_with_hash( Ok(()) } -impl ImageMeta { - /// Create a new image by downloading and extracting - pub async fn create(name: &str) -> Result { - debug!("Fetching image {} ...", name); +impl Image { + pub fn name(&self) -> &str { + &self.name + } - let dirs = QleanDirs::new()?; + pub fn path(&self) -> &PathBuf { + &self.path + } - if let Ok(image) = Self::load(name).await { - debug!("Using cached image."); - return Ok(image); - } + pub fn guest_arch(&self) -> GuestArch { + self.arch + } +} - let image_dir = dirs.images.join(name); - if image_dir.exists() { - tokio::fs::remove_dir_all(&image_dir).await?; +impl Image { + /// Create a new image using an explicit vendor value (supports per-image `arch` and optional `source` + `digest` overrides on built-in distros). + pub async fn new>(config: C) -> Result { + let config = config.as_ref(); + let override_source = config.source.as_deref().map(resolve_image_source); + let override_digest = config + .digest + .as_deref() + .map(parse_prefixed_digest) + .transpose()?; + + let name = image_cache_name(config.distro, config.arch, &override_source)?; + + if let Ok(image) = Self::load(&name).await { + return Ok(image); } - tokio::fs::create_dir_all(&image_dir).await?; - let distro_action = A::default(); + let dirs = QleanDirs::new()?; + let image_path = dirs.images.join(format!("{}.qcow2", name)); + let image_digest: (ShaType, String); + + if let (Some(src), Some((digest_type, digest_hex))) = (override_source, override_digest) { + // If source and digest are provided, fetch from source. + fetch_from_source(&src, &image_path, &digest_hex, digest_type.clone()).await?; + + image_digest = (digest_type, digest_hex); + } else { + // Otherwise, fetch from builtin remote image. + let spec = builtin_remote_image(config.distro, config.arch)?; + let expected_hash = fetch_expected_hash(&spec).await?; + + fetch_from_source( + &ImageSource::Url(spec.image_url.to_string()), + &image_path, + &expected_hash, + spec.checksum_type.clone(), + ) + .await?; - distro_action.download(name).await?; + image_digest = (spec.checksum_type, expected_hash); + } - let (kernel, initrd) = distro_action.extract(name).await?; - let image_path = image_dir.join(format!("{}.qcow2", name)); - let checksum_path = image_dir.join("checksums"); - let checksum = ShaSum { - path: checksum_path, - sha_type: ShaType::Sha512, - }; - let image = ImageMeta { - path: image_path, - kernel, - initrd, - checksum, + let image = Image { name: name.to_string(), - vendor: distro_action, + path: image_path, + arch: config.arch, + distro: config.distro, + digest: image_digest, }; - image.save(name).await?; + image.save(&name).await?; Ok(image) } @@ -294,35 +643,22 @@ impl ImageMeta { .await .with_context(|| format!("failed to read config file at {}", json_path.display()))?; - let image: ImageMeta = serde_json::from_str(&json_content) + let image: Image = serde_json::from_str(&json_content) .with_context(|| format!("failed to parse JSON from {}", json_path.display()))?; - let checksum_dir = dirs.images.join(name); - let checksum_command = match image.checksum.sha_type { - ShaType::Sha256 => "sha256sum", - ShaType::Sha512 => "sha512sum", - }; - - let output = tokio::process::Command::new(checksum_command) - .arg("-c") - .arg(&image.checksum.path) - .arg("--quiet") - .current_dir(&checksum_dir) - .output() - .await - .with_context(|| format!("failed to execute {} -c", checksum_command))?; - - if !output.status.success() { - bail!( - "checksum verification failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + info!("🔍 Validating cached image"); + let computed = compute_hash(&image.path, image.digest.0.clone()).await?; + anyhow::ensure!( + computed.eq_ignore_ascii_case(&image.digest.1), + "hash mismatch: expected {}, got {}", + image.digest.1, + computed + ); Ok(image) } - /// Save image metadata to disk using streaming hash + /// Save image metadata to disk using streaming hash. async fn save(&self, name: &str) -> Result<()> { let dirs = QleanDirs::new()?; let json_path = dirs.images.join(format!("{}.json", name)); @@ -334,916 +670,152 @@ impl ImageMeta { .await .with_context(|| format!("failed to write image config to {}", json_path.display()))?; - // Use streaming hash for best performance (7-27% faster in release mode) - let (image_hash, kernel_hash, initrd_hash) = match self.checksum.sha_type { - ShaType::Sha256 => ( - compute_sha256_streaming(&self.path).await?, - compute_sha256_streaming(&self.kernel).await?, - compute_sha256_streaming(&self.initrd).await?, - ), - ShaType::Sha512 => ( - compute_sha512_streaming(&self.path).await?, - compute_sha512_streaming(&self.kernel).await?, - compute_sha512_streaming(&self.initrd).await?, - ), - }; - - let image_filename = self - .path - .file_name() - .with_context(|| "failed to get image filename")? - .to_string_lossy(); - let kernel_filename = self - .kernel - .file_name() - .with_context(|| "failed to get kernel filename")? - .to_string_lossy(); - let initrd_filename = self - .initrd - .file_name() - .with_context(|| "failed to get initrd filename")? - .to_string_lossy(); - - let checksum_content = format!( - "{} {}\n{} {}\n{} {}\n", - image_hash, image_filename, kernel_hash, kernel_filename, initrd_hash, initrd_filename - ); - - tokio::fs::write(&self.checksum.path, checksum_content) - .await - .with_context(|| { - format!( - "failed to write checksum file to {}", - self.checksum.path.display() - ) - })?; - - Ok(()) - } -} - -// Special create method for Custom images (non-Default trait) -impl ImageMeta { - /// Create image with custom action for non-Default implementations - pub async fn create_with_action(name: &str, action: A) -> Result { - debug!("Fetching image {} with custom action ...", name); - - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - if image_dir.exists() { - tokio::fs::remove_dir_all(&image_dir).await?; - } - tokio::fs::create_dir_all(&image_dir).await?; - - action.download(name).await?; - - let (kernel, initrd) = action.extract(name).await?; - let image_path = image_dir.join(format!("{}.qcow2", name)); - let checksum_path = image_dir.join("checksums"); - let checksum = ShaSum { - path: checksum_path, - sha_type: ShaType::Sha512, - }; - let image = ImageMeta { - path: image_path, - kernel, - initrd, - checksum, - name: name.to_string(), - vendor: action, - }; - - // Inline save with streaming hash - let json_path = dirs.images.join(format!("{}.json", name)); - let json_content = serde_json::to_string_pretty(&image)?; - tokio::fs::write(&json_path, json_content).await?; - - let (image_hash, kernel_hash, initrd_hash) = match image.checksum.sha_type { - ShaType::Sha256 => ( - compute_sha256_streaming(&image.path).await?, - compute_sha256_streaming(&image.kernel).await?, - compute_sha256_streaming(&image.initrd).await?, - ), - ShaType::Sha512 => ( - compute_sha512_streaming(&image.path).await?, - compute_sha512_streaming(&image.kernel).await?, - compute_sha512_streaming(&image.initrd).await?, - ), - }; - - let image_filename = image.path.file_name().unwrap().to_string_lossy(); - let kernel_filename = image.kernel.file_name().unwrap().to_string_lossy(); - let initrd_filename = image.initrd.file_name().unwrap().to_string_lossy(); - - let checksum_content = format!( - "{} {}\n{} {}\n{} {}\n", - image_hash, image_filename, kernel_hash, kernel_filename, initrd_hash, initrd_filename - ); - - tokio::fs::write(&image.checksum.path, checksum_content).await?; - - Ok(image) - } -} - -// --------------------------------------------------------------------------- -// Debian -// --------------------------------------------------------------------------- - -#[derive(Debug, Default)] -pub struct Debian {} - -impl ImageAction for Debian { - async fn download(&self, name: &str) -> Result<()> { - let checksums_url = "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS"; - let checksums_text = reqwest::get(checksums_url) - .await - .with_context(|| format!("failed to download SHA512SUMS from {}", checksums_url))? - .text() - .await - .with_context(|| format!("failed to read SHA512SUMS text from {}", checksums_url))?; - - let target_filename = format!("{}.qcow2", name); - let expected_sha512 = find_sha512_for_file(&checksums_text, &target_filename) - .with_context(|| { - format!( - "failed to find SHA512 checksum entry for {} in remote SHA512SUMS file", - target_filename - ) - })?; - - let dirs = QleanDirs::new()?; - let image_path = dirs.images.join(name).join(&target_filename); - - let download_url = format!( - "https://cloud.debian.org/images/cloud/trixie/latest/{}.qcow2", - name - ); - - // Single-pass download + hash computation - let computed_sha512 = - download_with_hash(&download_url, &image_path, ShaType::Sha512).await?; - - // Verify the downloaded file matches the expected checksum - anyhow::ensure!( - computed_sha512.to_lowercase() == expected_sha512.to_lowercase(), - "downloaded image checksum mismatch: expected {}, got {}", - expected_sha512, - computed_sha512 - ); - - Ok(()) - } - - async fn extract(&self, name: &str) -> Result<(PathBuf, PathBuf)> { - let file_name = format!("{}.qcow2", name); - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - let output = tokio::process::Command::new("guestfish") - .arg("--ro") - .arg("-a") - .arg(&file_name) - .arg("-i") - .arg("ls") - .arg("/boot") - .current_dir(&image_dir) - .output() - .await - .with_context(|| "failed to execute guestfish")?; - - if !output.status.success() { - bail!( - "guestfish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; - - for line in boot_files.lines() { - let file = line.trim(); - if file.starts_with("vmlinuz") { - kernel_name = Some(file.to_string()); - } else if file.starts_with("initrd.img") { - initrd_name = Some(file.to_string()); - } - } - - let kernel_name = - kernel_name.with_context(|| "failed to find kernel file (vmlinuz*) in /boot")?; - let initrd_name = - initrd_name.with_context(|| "failed to find initrd file (initrd.img*) in /boot")?; - - let kernel_src = format!("/boot/{}", kernel_name); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&kernel_src) - .arg(".") - .current_dir(&image_dir) - .output() - .await - .with_context(|| format!("failed to execute virt-copy-out for {}", kernel_name))?; - - if !output.status.success() { - bail!( - "virt-copy-out failed for kernel: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let initrd_src = format!("/boot/{}", initrd_name); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&initrd_src) - .arg(".") - .current_dir(&image_dir) - .output() - .await - .with_context(|| format!("failed to execute virt-copy-out for {}", initrd_name))?; - - if !output.status.success() { - bail!( - "virt-copy-out failed for initrd: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let kernel_path = image_dir.join(&kernel_name); - let initrd_path = image_dir.join(&initrd_name); - - Ok((kernel_path, initrd_path)) - } - - fn distro(&self) -> Distro { - Distro::Debian - } -} - -// --------------------------------------------------------------------------- -// Ubuntu - uses pre-extracted kernel/initrd from official cloud images -// --------------------------------------------------------------------------- - -#[derive(Debug, Default)] -pub struct Ubuntu {} - -impl ImageAction for Ubuntu { - async fn download(&self, name: &str) -> Result<()> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Ubuntu noble (24.04 LTS) cloud image base URL - let base_url = "https://cloud-images.ubuntu.com/noble/current"; - - // Download qcow2 image - let qcow2_url = format!("{}/noble-server-cloudimg-amd64.img", base_url); - let qcow2_path = image_dir.join(format!("{}.qcow2", name)); - download_file(&qcow2_url, &qcow2_path).await?; - - // Download pre-extracted kernel - let kernel_url = format!( - "{}/unpacked/noble-server-cloudimg-amd64-vmlinuz-generic", - base_url - ); - let kernel_path = image_dir.join("vmlinuz"); - download_file(&kernel_url, &kernel_path).await?; - - // Download pre-extracted initrd - let initrd_url = format!( - "{}/unpacked/noble-server-cloudimg-amd64-initrd-generic", - base_url - ); - let initrd_path = image_dir.join("initrd.img"); - download_file(&initrd_url, &initrd_path).await?; - Ok(()) } - - async fn extract(&self, name: &str) -> Result<(PathBuf, PathBuf)> { - // Files already downloaded in download() phase - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - let kernel = image_dir.join("vmlinuz"); - let initrd = image_dir.join("initrd.img"); - - anyhow::ensure!(kernel.exists(), "kernel file not found after download"); - anyhow::ensure!(initrd.exists(), "initrd file not found after download"); - - Ok((kernel, initrd)) - } - - fn distro(&self) -> Distro { - Distro::Ubuntu - } } -// --------------------------------------------------------------------------- -// Fedora - uses pre-extracted kernel/initrd from official cloud images -// --------------------------------------------------------------------------- - -#[derive(Debug, Default)] -pub struct Fedora {} - -impl ImageAction for Fedora { - async fn download(&self, name: &str) -> Result<()> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Fedora 41 Cloud Base image - let base_url = - "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images"; - - // Image filename - let image_filename = "Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2"; - - // Download qcow2 image - let qcow2_url = format!("{}/{}", base_url, image_filename); - let qcow2_path = image_dir.join(format!("{}.qcow2", name)); - download_file(&qcow2_url, &qcow2_path).await?; - - // Fedora cloud images don't provide pre-extracted boot files - // We'll need to extract them using guestfish - Ok(()) - } - - async fn extract(&self, name: &str) -> Result<(PathBuf, PathBuf)> { - let file_name = format!("{}.qcow2", name); - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Use guestfish to list boot files - let output = tokio::process::Command::new("guestfish") - .arg("--ro") - .arg("-a") - .arg(&file_name) - .arg("-i") - .arg("ls") - .arg("/boot") - .current_dir(&image_dir) - .output() - .await - .with_context(|| "failed to execute guestfish")?; - - if !output.status.success() { - bail!( - "guestfish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; - - for line in boot_files.lines() { - let file = line.trim(); - if file.starts_with("vmlinuz") { - kernel_name = Some(file.to_string()); - } else if file.starts_with("initramfs") { - initrd_name = Some(file.to_string()); - } - } - - let kernel_name = - kernel_name.with_context(|| "failed to find kernel file (vmlinuz*) in /boot")?; - let initrd_name = - initrd_name.with_context(|| "failed to find initrd file (initramfs*) in /boot")?; - - // Extract kernel - let kernel_src = format!("/boot/{}", kernel_name); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&kernel_src) - .arg(".") - .current_dir(&image_dir) - .output() - .await - .with_context(|| format!("failed to execute virt-copy-out for {}", kernel_name))?; - - if !output.status.success() { - bail!( - "virt-copy-out failed for kernel: {}", - String::from_utf8_lossy(&output.stderr) - ); - } +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; - // Extract initrd - let initrd_src = format!("/boot/{}", initrd_name); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&initrd_src) - .arg(".") - .current_dir(&image_dir) + /// Calculate SHA256 with command line tool `sha256sum` + async fn get_sha256(path: &PathBuf) -> Result { + let output = tokio::process::Command::new("sha256sum") + .arg(path) .output() .await - .with_context(|| format!("failed to execute virt-copy-out for {}", initrd_name))?; + .with_context(|| format!("failed to execute sha256sum on {}", path.display()))?; if !output.status.success() { bail!( - "virt-copy-out failed for initrd: {}", + "sha256sum failed: {}", String::from_utf8_lossy(&output.stderr) ); } - let kernel_path = image_dir.join(&kernel_name); - let initrd_path = image_dir.join(&initrd_name); - - Ok((kernel_path, initrd_path)) - } - - fn distro(&self) -> Distro { - Distro::Fedora - } -} - -// --------------------------------------------------------------------------- -// Arch - uses official cloud images -// --------------------------------------------------------------------------- - -#[derive(Debug, Default)] -pub struct Arch {} - -impl ImageAction for Arch { - async fn download(&self, name: &str) -> Result<()> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Arch Linux cloud image (using latest) - let base_url = "https://geo.mirror.pkgbuild.com/images/latest"; - let image_filename = "Arch-Linux-x86_64-cloudimg.qcow2"; - - // Download qcow2 image - let qcow2_url = format!("{}/{}", base_url, image_filename); - let qcow2_path = image_dir.join(format!("{}.qcow2", name)); - download_file(&qcow2_url, &qcow2_path).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let sha256 = stdout + .split_whitespace() + .next() + .with_context(|| "failed to parse sha256sum output")? + .to_string(); - Ok(()) + Ok(sha256) } - async fn extract(&self, name: &str) -> Result<(PathBuf, PathBuf)> { - let file_name = format!("{}.qcow2", name); - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Use guestfish to list boot files - let output = tokio::process::Command::new("guestfish") - .arg("--ro") - .arg("-a") - .arg(&file_name) - .arg("-i") - .arg("ls") - .arg("/boot") - .current_dir(&image_dir) - .output() - .await - .with_context(|| "failed to execute guestfish")?; - - if !output.status.success() { - bail!( - "guestfish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; - - for line in boot_files.lines() { - let file = line.trim(); - // Arch uses vmlinuz-linux - if file.starts_with("vmlinuz") { - kernel_name = Some(file.to_string()); - } else if file.starts_with("initramfs") && file.contains("linux.img") { - initrd_name = Some(file.to_string()); - } - } - - let kernel_name = - kernel_name.with_context(|| "failed to find kernel file (vmlinuz*) in /boot")?; - let initrd_name = initrd_name - .with_context(|| "failed to find initrd file (initramfs*linux.img) in /boot")?; - - // Extract kernel - let kernel_src = format!("/boot/{}", kernel_name); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&kernel_src) - .arg(".") - .current_dir(&image_dir) - .output() - .await - .with_context(|| format!("failed to execute virt-copy-out for {}", kernel_name))?; - - if !output.status.success() { - bail!( - "virt-copy-out failed for kernel: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - // Extract initrd - let initrd_src = format!("/boot/{}", initrd_name); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&initrd_src) - .arg(".") - .current_dir(&image_dir) + /// Calculate SHA512 with command line tool `sha512sum` + async fn get_sha512(path: &PathBuf) -> Result { + let output = tokio::process::Command::new("sha512sum") + .arg(path) .output() .await - .with_context(|| format!("failed to execute virt-copy-out for {}", initrd_name))?; + .with_context(|| format!("failed to execute sha512sum on {}", path.display()))?; if !output.status.success() { bail!( - "virt-copy-out failed for initrd: {}", + "sha512sum failed: {}", String::from_utf8_lossy(&output.stderr) ); } - let kernel_path = image_dir.join(&kernel_name); - let initrd_path = image_dir.join(&initrd_name); + let stdout = String::from_utf8_lossy(&output.stdout); + let sha512 = stdout + .split_whitespace() + .next() + .with_context(|| "failed to parse sha512sum output")? + .to_string(); - Ok((kernel_path, initrd_path)) + Ok(sha512) } - fn distro(&self) -> Distro { - Distro::Arch - } -} - -// --------------------------------------------------------------------------- -// Custom - user-provided image with flexible configuration (WSL-friendly) -// --------------------------------------------------------------------------- - -#[derive(Debug)] -pub struct Custom { - pub config: CustomImageConfig, -} - -impl Custom { - pub fn new(config: CustomImageConfig) -> Self { - Custom { config } - } -} - -impl ImageAction for Custom { - async fn download(&self, name: &str) -> Result<()> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Download main image file - let image_path = image_dir.join(format!("{}.qcow2", name)); - download_or_copy_with_hash( - &self.config.image_source, - &image_path, - &self.config.image_hash, - self.config.image_hash_type.clone(), - ) - .await?; - - // Download kernel if provided - if let (Some(kernel_src), Some(kernel_hash)) = - (&self.config.kernel_source, &self.config.kernel_hash) - { - let kernel_path = image_dir.join("vmlinuz"); - download_or_copy_with_hash( - kernel_src, - &kernel_path, - kernel_hash, - self.config.image_hash_type.clone(), - ) - .await?; - } - - // Download initrd if provided - if let (Some(initrd_src), Some(initrd_hash)) = - (&self.config.initrd_source, &self.config.initrd_hash) - { - let initrd_path = image_dir.join("initrd.img"); - download_or_copy_with_hash( - initrd_src, - &initrd_path, - initrd_hash, - self.config.image_hash_type.clone(), - ) - .await?; - } - - Ok(()) - } - - async fn extract(&self, name: &str) -> Result<(PathBuf, PathBuf)> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); - - // Check if kernel/initrd were pre-provided - let kernel_path = image_dir.join("vmlinuz"); - let initrd_path = image_dir.join("initrd.img"); - - if kernel_path.exists() && initrd_path.exists() { - debug!("Using pre-provided kernel and initrd files"); - return Ok((kernel_path, initrd_path)); - } - - // Otherwise, try to extract using guestfish - let file_name = format!("{}.qcow2", name); - - let output = tokio::process::Command::new("guestfish") - .arg("--ro") - .arg("-a") - .arg(&file_name) - .arg("-i") - .arg("ls") - .arg("/boot") - .current_dir(&image_dir) - .output() - .await; - - if let Ok(output) = output - && output.status.success() - { - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; - - // Generic kernel/initrd detection - for line in boot_files.lines() { - let file = line.trim(); - if kernel_name.is_none() - && (file.starts_with("vmlinuz") || file.starts_with("bzImage")) - { - kernel_name = Some(file.to_string()); - } - if initrd_name.is_none() - && (file.starts_with("initrd") || file.starts_with("initramfs")) - { - initrd_name = Some(file.to_string()); - } - } - - if let (Some(kernel), Some(initrd)) = (kernel_name, initrd_name) { - // Extract using virt-copy-out - for (file, desc) in [(&kernel, "kernel"), (&initrd, "initrd")] { - let src = format!("/boot/{}", file); - let output = tokio::process::Command::new("virt-copy-out") - .arg("-a") - .arg(&file_name) - .arg(&src) - .arg(".") - .current_dir(&image_dir) - .output() - .await?; - - if !output.status.success() { - bail!("virt-copy-out failed for {}", desc); - } - } - - return Ok((image_dir.join(&kernel), image_dir.join(&initrd))); - } - } - - // Guestfish not available or failed - provide helpful error - bail!( - "Custom image requires either:\n\ - \n\ - 1. Pre-extracted boot files (RECOMMENDED for WSL):\n\ - - Provide kernel_source, kernel_hash, initrd_source, initrd_hash in config\n\ - - See documentation for examples\n\ - \n\ - 2. Guestfish for extraction (native Linux only):\n\ - - Install: sudo apt install libguestfs-tools\n\ - - Provide only image_source/image_hash in config\n\ - - Not supported on WSL/WSL2" + #[test] + fn test_find_hash_for_exact_filename() { + let checksums = "\ +748f52b959f63352e1e121508cedeae2e66d3e90be00e6420a0b8b9f14a0f84dc54ed801fb5be327866876268b808543465b1613c8649efeeb5f987ff9df1549 debian-13-generic-amd64.json +\ +f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea67709154d84220059672758508afbb0691c41ba8aa6d76818d89d65 debian-13-generic-amd64.qcow2"; + let result = find_hash_for_file(checksums, "debian-13-generic-amd64.qcow2"); + assert_eq!( + result, + Some("f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea67709154d84220059672758508afbb0691c41ba8aa6d76818d89d65".to_string()) ); } - fn distro(&self) -> Distro { - Distro::Custom - } -} - -// Helper function to download a file -async fn download_file(url: &str, dest: &PathBuf) -> Result<()> { - debug!("Downloading {} to {}", url, dest.display()); - let response = reqwest::get(url) - .await - .with_context(|| format!("failed to download from {}", url))?; - - let mut file = File::create(dest) - .await - .with_context(|| format!("failed to create file at {}", dest.display()))?; - - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.with_context(|| "failed to read chunk from stream")?; - file.write_all(&chunk) - .await - .with_context(|| "failed to write to file")?; - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Image wrapper enum -// --------------------------------------------------------------------------- - -/// Wrapper enum for different Image types -#[derive(Debug)] -pub enum Image { - Debian(ImageMeta), - Ubuntu(ImageMeta), - Fedora(ImageMeta), - Arch(ImageMeta), - Custom(ImageMeta), -} - -impl Image { - /// Get the underlying name regardless of distro - pub fn name(&self) -> &str { - match self { - Image::Debian(img) => &img.name, - Image::Ubuntu(img) => &img.name, - Image::Fedora(img) => &img.name, - Image::Arch(img) => &img.name, - Image::Custom(img) => &img.name, - } - } - - /// Get the underlying image path regardless of distro - pub fn path(&self) -> &PathBuf { - match self { - Image::Debian(img) => &img.path, - Image::Ubuntu(img) => &img.path, - Image::Fedora(img) => &img.path, - Image::Arch(img) => &img.path, - Image::Custom(img) => &img.path, - } - } - - /// Get the kernel path regardless of distro - pub fn kernel(&self) -> &PathBuf { - match self { - Image::Debian(img) => &img.kernel, - Image::Ubuntu(img) => &img.kernel, - Image::Fedora(img) => &img.kernel, - Image::Arch(img) => &img.kernel, - Image::Custom(img) => &img.kernel, - } - } + #[test] + fn test_image_config_serde() { + let config = ImageConfig { + arch: GuestArch::Amd64, + distro: Distro::Debian, + source: Some("https://example.com/image.qcow2".to_string()), + digest: Some("sha256:abcdef123456".to_string()), + }; - /// Get the initrd path regardless of distro - pub fn initrd(&self) -> &PathBuf { - match self { - Image::Debian(img) => &img.initrd, - Image::Ubuntu(img) => &img.initrd, - Image::Fedora(img) => &img.initrd, - Image::Arch(img) => &img.initrd, - Image::Custom(img) => &img.initrd, - } - } -} + let json = serde_json::to_string(&config).unwrap(); + let decoded: ImageConfig = serde_json::from_str(&json).unwrap(); -/// Factory function to create Image instances based on distro -pub async fn create_image(distro: Distro, name: &str) -> Result { - match distro { - Distro::Debian => { - let image = ImageMeta::::create(name).await?; - Ok(Image::Debian(image)) - } - Distro::Ubuntu => { - let image = ImageMeta::::create(name).await?; - Ok(Image::Ubuntu(image)) - } - Distro::Fedora => { - let image = ImageMeta::::create(name).await?; - Ok(Image::Fedora(image)) - } - Distro::Arch => { - let image = ImageMeta::::create(name).await?; - Ok(Image::Arch(image)) - } - Distro::Custom => { - bail!("use create_custom_image() for custom images"); - } + assert_eq!(decoded, config); } -} - -/// Factory function for custom images -pub async fn create_custom_image(name: &str, config: CustomImageConfig) -> Result { - let action = Custom::new(config); - let image = ImageMeta::create_with_action(name, action).await?; - Ok(Image::Custom(image)) -} -/// Calculate SHA256 with command line tool `sha256sum` -pub async fn get_sha256(path: &PathBuf) -> Result { - let output = tokio::process::Command::new("sha256sum") - .arg(path) - .output() - .await - .with_context(|| format!("failed to execute sha256sum on {}", path.display()))?; + #[test] + fn test_builtin_remote_image_specs() { + let d = builtin_remote_image(Distro::Debian, GuestArch::Amd64).unwrap(); + assert!(d.image_url.contains("debian-13-generic-amd64.qcow2")); + assert_eq!(d.checksum_type, ShaType::Sha512); + assert_eq!(d.checksum_entry, "debian-13-generic-amd64.qcow2"); - if !output.status.success() { - bail!( - "sha256sum failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + assert!(builtin_remote_image(Distro::Debian, GuestArch::Aarch64).is_err()); } - let stdout = String::from_utf8_lossy(&output.stdout); - let sha256 = stdout - .split_whitespace() - .next() - .with_context(|| "failed to parse sha256sum output")? - .to_string(); - - Ok(sha256) -} - -/// Calculate SHA512 with command line tool `sha512sum` -pub async fn get_sha512(path: &PathBuf) -> Result { - let output = tokio::process::Command::new("sha512sum") - .arg(path) - .output() - .await - .with_context(|| format!("failed to execute sha512sum on {}", path.display()))?; + #[test] + fn test_parse_prefixed_digest_case_insensitive() { + let (algo, hex) = parse_prefixed_digest("SHA512:AbCd").unwrap(); + assert_eq!(algo, ShaType::Sha512); + assert_eq!(hex, "AbCd"); - if !output.status.success() { - bail!( - "sha512sum failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + assert!(parse_prefixed_digest("md5:abcd").is_err()); } - let stdout = String::from_utf8_lossy(&output.stdout); - let sha512 = stdout - .split_whitespace() - .next() - .with_context(|| "failed to parse sha512sum output")? - .to_string(); - - Ok(sha512) -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - #[test] - fn test_find_sha512_for_exact_filename() { + fn test_find_hash_for_file_fedora_pgp_signed_checksum() { let checksums = "\ -748f52b959f63352e1e121508cedeae2e66d3e90be00e6420a0b8b9f14a0f84dc54ed801fb5be327866876268b808543465b1613c8649efeeb5f987ff9df1549 debian-13-generic-amd64.json -\ -f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea67709154d84220059672758508afbb0691c41ba8aa6d76818d89d65 debian-13-generic-amd64.qcow2"; - let result = find_sha512_for_file(checksums, "debian-13-generic-amd64.qcow2"); +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +# Fedora Cloud image +deadbeef00112233 *Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2 +other000000000000 *other.img +-----BEGIN PGP SIGNATURE----- +dummy +-----END PGP SIGNATURE-----"; assert_eq!( - result, - Some("f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea67709154d84220059672758508afbb0691c41ba8aa6d76818d89d65".to_string()) + find_hash_for_file(checksums, "Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2"), + Some("deadbeef00112233".to_string()) ); } #[test] - fn test_distro_enum_variants() { - let variants = vec![ - Distro::Debian, - Distro::Ubuntu, - Distro::Fedora, - Distro::Arch, - Distro::Custom, - ]; - assert_eq!(variants.len(), 5); - } - - #[test] - fn test_custom_image_config_serde() { - let config = CustomImageConfig { - image_source: ImageSource::Url("https://example.com/image.qcow2".to_string()), - image_hash: "abcdef123456".to_string(), - image_hash_type: ShaType::Sha256, - kernel_source: Some(ImageSource::Url("https://example.com/vmlinuz".to_string())), - kernel_hash: Some("kernel123".to_string()), - initrd_source: Some(ImageSource::Url("https://example.com/initrd".to_string())), - initrd_hash: Some("initrd456".to_string()), - }; + fn test_find_hash_for_file_formats() { + // Format 1: " " + let f1 = "abc123 foo.bin\n012345 bar.bin"; + assert_eq!( + find_hash_for_file(f1, "bar.bin"), + Some("012345".to_string()) + ); - let json = serde_json::to_string(&config).unwrap(); - let decoded: CustomImageConfig = serde_json::from_str(&json).unwrap(); + // Format 2: "SHA256 () = " + let f2 = "SHA256 (image.qcow2) = deadbeef\nSHA256 (other) = 00"; + assert_eq!( + find_hash_for_file(f2, "image.qcow2"), + Some("deadbeef".to_string()) + ); - assert_eq!(decoded.image_hash, "abcdef123456"); - assert_eq!(decoded.kernel_hash, Some("kernel123".to_string())); + // Format 2: SHA512 variant + let f3 = "SHA512 (k) = aaa\nSHA512 (initrd.img) = bbb"; + assert_eq!( + find_hash_for_file(f3, "initrd.img"), + Some("bbb".to_string()) + ); } #[tokio::test] @@ -1251,7 +823,7 @@ f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea6770915 let tmp = tempfile::NamedTempFile::new()?; let path = tmp.path(); - let hash = compute_sha256_streaming(path).await?; + let hash = compute_hash(path, ShaType::Sha256).await?; // SHA-256 of empty file assert_eq!( @@ -1275,7 +847,7 @@ f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea6770915 } let shell = get_sha256(&path).await?; - let stream = compute_sha256_streaming(&path).await?; + let stream = compute_hash(&path, ShaType::Sha256).await?; assert_eq!(shell, stream, "streaming must match shell"); @@ -1295,7 +867,7 @@ f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea6770915 } let shell = get_sha512(&path).await?; - let stream = compute_sha512_streaming(&path).await?; + let stream = compute_hash(&path, ShaType::Sha512).await?; assert_eq!(shell, stream, "streaming must match shell"); diff --git a/src/lib.rs b/src/lib.rs index d782cf5..8dd22c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,5 @@ use std::future::Future; use std::pin::Pin; -use std::sync::OnceLock; use anyhow::Result; use kvm_ioctls::Kvm; @@ -15,22 +14,26 @@ mod ssh; mod utils; // Re-export public types and functions -pub use image::CustomImageConfig; pub use image::Distro; +pub use image::GuestArch; pub use image::Image; +pub use image::ImageConfig; pub use image::ImageSource; pub use image::ShaType; -pub use image::compute_sha256_streaming; -pub use image::compute_sha512_streaming; -pub use image::create_custom_image; -pub use image::create_image; -pub use image::get_sha256; -pub use image::get_sha512; pub use machine::{Machine, MachineConfig}; pub use pool::MachinePool; -static KVM_AVAILABLE: OnceLock = OnceLock::new(); +/// Check if KVM is available. +pub fn is_kvm_available() -> bool { + #[cfg(not(target_os = "linux"))] + { + return false; + } + Kvm::new().is_ok() +} + +/// Execute a closure with a machine. pub async fn with_machine<'a, F, R>(image: &'a Image, config: &'a MachineConfig, f: F) -> Result where F: for<'b> FnOnce(&'b mut Machine) -> Pin> + 'b>>, @@ -42,8 +45,6 @@ where ensure_prerequisites().await?; - KVM_AVAILABLE.get_or_init(|| Kvm::new().is_ok()); - let mut machine = Machine::new(image, config).await?; machine.init().await?; let result = f(&mut machine).await; @@ -52,6 +53,7 @@ where result } +/// Execute a closure with a machine pool. pub async fn with_pool(f: F) -> Result where F: for<'a> FnOnce(&'a mut MachinePool) -> Pin> + 'a>>, @@ -63,8 +65,6 @@ where ensure_prerequisites().await?; - KVM_AVAILABLE.get_or_init(|| Kvm::new().is_ok()); - let mut pool = MachinePool::new(); let result = f(&mut pool).await; pool.shutdown_all().await?; diff --git a/src/machine.rs b/src/machine.rs index 0e082ad..a8f9516 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -20,8 +20,8 @@ use tracing::{debug, info}; use walkdir::WalkDir; use crate::{ - KVM_AVAILABLE, - image::Image, + image::{GuestArch, Image}, + is_kvm_available, qemu::launch_qemu, ssh::{PersistedSshKeypair, Session, connect_ssh, get_ssh_key}, utils::{CommandExt, HEX_ALPHABET, QleanDirs, gen_random_mac, get_free_cid}, @@ -51,8 +51,7 @@ pub struct Machine { #[derive(Clone)] pub struct MachineImage { pub overlay: PathBuf, - pub kernel: PathBuf, - pub initrd: PathBuf, + pub arch: GuestArch, pub seed: PathBuf, } @@ -80,6 +79,39 @@ pub struct MetaData { pub struct UserData { pub disable_root: bool, pub ssh_authorized_keys: Vec, + + /// Optional cloud-init directives used to configure the guest at first boot. + /// + /// We use these to enable an SSH listener on vhost-vsock so that Qlean can + /// reach the guest without relying on TCP port forwarding. + #[serde(skip_serializing_if = "Option::is_none")] + pub write_files: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub runcmd: Option>>, + + /// Additional cloud-init configuration. + /// + /// This is intentionally a loose YAML value so we can support a mix of distro + /// defaults (Ubuntu/Fedora/Arch) without encoding every schema detail in Rust. + #[serde(skip_serializing_if = "Option::is_none")] + pub users: Option, + + /// Explicitly disable password authentication. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_pwauth: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CloudInitWriteFile { + pub path: String, + pub content: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, } impl Default for MachineConfig { @@ -144,24 +176,165 @@ impl Machine { meta_data_str.insert_str(0, "#cloud-config\n"); debug!("Writing cloud-init meta-data:\n{}", meta_data_str); tokio::fs::write(seed_dir.join("meta-data"), meta_data_str).await?; + + // Enable an SSH path over vhost-vsock without requiring OpenSSH to accept an AF_VSOCK + // socket directly. + // + // VSOCK-only: provide SSH access exclusively via vhost-vsock. + // + // We intentionally do NOT depend on the guest distro enabling/running sshd.service. + // Instead, we use systemd socket activation and run `sshd -i` (inetd mode) for each + // incoming vsock connection. + // + // IMPORTANT: StandardOutput must be wired to the socket; otherwise clients may connect + // but never receive an SSH banner (hangs until handshake timeout). + let sshd_wrapper = r#"#!/bin/sh +set -eu + +for p in /usr/sbin/sshd /usr/bin/sshd /sbin/sshd; do + if [ -x "$p" ]; then + exec "$p" "$@" + fi +done + +echo "qlean: sshd not found" >&2 +exit 127 +"# + .to_string(); + + let vsock_socket_unit = r#"[Unit] +Description=Qlean SSH over vhost-vsock (socket-activated sshd) + +[Socket] +ListenStream=vsock::22 +Accept=yes + +[Install] +WantedBy=sockets.target +"# + .to_string(); + + let vsock_service_unit = r#"[Unit] +Description=Qlean SSH over vhost-vsock (per-connection sshd) + +[Service] +ExecStart=/usr/bin/qlean-sshd-run -i -e \ + -o PermitRootLogin=yes \ + -o PasswordAuthentication=no \ + -o PubkeyAuthentication=yes \ + -o AuthorizedKeysFile=/root/.ssh/authorized_keys \ + -o StrictModes=yes +StandardInput=socket +StandardOutput=socket +StandardError=journal +"# + .to_string(); + let user_data = UserData { disable_root: false, ssh_authorized_keys: vec![ssh_keypair.pubkey_str.clone()], + write_files: Some(vec![ + CloudInitWriteFile { + path: "/etc/systemd/system/qlean-sshd-vsock.socket".to_string(), + content: vsock_socket_unit, + permissions: Some("0644".to_string()), + owner: Some("root:root".to_string()), + }, + CloudInitWriteFile { + path: "/etc/systemd/system/qlean-sshd-vsock@.service".to_string(), + content: vsock_service_unit, + permissions: Some("0644".to_string()), + owner: Some("root:root".to_string()), + }, + CloudInitWriteFile { + // /usr/bin is a safe location across distros, including SELinux-enforcing Fedora. + path: "/usr/bin/qlean-sshd-run".to_string(), + content: sshd_wrapper, + permissions: Some("0755".to_string()), + owner: Some("root:root".to_string()), + }, + CloudInitWriteFile { + path: "/root/.ssh/authorized_keys".to_string(), + content: format!("{}\n", ssh_keypair.pubkey_str), + permissions: Some("0600".to_string()), + owner: Some("root:root".to_string()), + }, + ]), + runcmd: Some(vec![ + // Ensure the vsock transport exists in the guest. + vec![ + "bash".to_string(), + "-lc".to_string(), + "modprobe vsock 2>/dev/null || true; modprobe vmw_vsock_virtio_transport 2>/dev/null || true; modprobe vhost_vsock 2>/dev/null || true".to_string(), + ], + // Ensure SSH host keys exist. + vec![ + "bash".to_string(), + "-lc".to_string(), + "command -v ssh-keygen >/dev/null && ssh-keygen -A || true".to_string(), + ], + vec![ + "bash".to_string(), + "-lc".to_string(), + "systemctl daemon-reload".to_string(), + ], + // Fedora images commonly run with SELinux enforcing; permissive avoids rare policy + // issues when starting our helper service. + vec![ + "bash".to_string(), + "-lc".to_string(), + "if command -v getenforce >/dev/null && command -v setenforce >/dev/null; then if [ \"$(getenforce 2>/dev/null)\" = \"Enforcing\" ]; then setenforce 0 || true; fi; fi".to_string(), + ], + // Ensure sshd runtime dirs exist. + vec![ + "bash".to_string(), + "-lc".to_string(), + "mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh || true".to_string(), + ], + // Enable the vsock sshd socket. + vec![ + "bash".to_string(), + "-lc".to_string(), + "systemctl enable --now qlean-sshd-vsock.socket".to_string(), + ], + // Marker to simplify debugging via virt-cat. + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo qlean-cloud-init-ok > /var/log/qlean-cloud-init.marker || true".to_string(), + ], + ]), + users: None, + ssh_pwauth: Some(false), }; let mut user_data_str = serde_yml::to_string(&user_data)?; user_data_str.insert_str(0, "#cloud-config\n"); debug!("Writing cloud-init user-data:\n{}", user_data_str); tokio::fs::write(seed_dir.join("user-data"), user_data_str).await?; + // cloud-init's NoCloud datasource expects both user-data and meta-data. + // If meta-data is missing, many images will ignore the seed ISO entirely, + // which means our SSH key and the vsock SSH proxy won't be configured. + let meta_data = format!("instance-id: qlean-{}\nlocal-hostname: qlean\n", machine_id); + tokio::fs::write(seed_dir.join("meta-data"), meta_data).await?; + // Prepare seed ISO let seed_iso_path = run_dir.join("seed.iso"); + // NoCloud expects user-data/meta-data at the *root* of the ISO. + // Passing the directory path directly would place it under /seed/ in the ISO, which + // some distros do not detect (leading to missing SSH key/proxy setup). + let user_data_path = seed_dir.join("user-data"); + let meta_data_path = seed_dir.join("meta-data"); + let mut xorriso_command = tokio::process::Command::new("xorriso"); xorriso_command .args(["-as", "mkisofs"]) .args(["-V", "cidata"]) .args(["-J", "-R"]) .args(["-o", seed_iso_path.to_str().unwrap()]) - .arg(seed_dir); + .args(["-graft-points"]) + .arg(format!("user-data={}", user_data_path.to_string_lossy())) + .arg(format!("meta-data={}", meta_data_path.to_string_lossy())); debug!( "Creating seed ISO with command:\n{:?}", xorriso_command.to_string() @@ -176,8 +349,7 @@ impl Machine { let machine_image = MachineImage { overlay: overlay_image.clone(), - kernel: image.kernel().to_owned(), - initrd: image.initrd().to_owned(), + arch: image.guest_arch(), seed: seed_iso_path, }; @@ -233,7 +405,7 @@ impl Machine { /// Spawn the machine (normal boot) pub async fn spawn(&mut self) -> Result<()> { - info!("Spawning VM-{}", self.id); + info!("🔥 Spawning VM-{}", self.id); if self.ssh_cancel_token.is_none() { self.ssh_cancel_token = Some(CancellationToken::new()); @@ -272,15 +444,32 @@ impl Machine { /// Shutdown the machine pub async fn shutdown(&mut self) -> Result<()> { if let Some(ssh) = self.ssh.as_mut() { - // Then shut the system down. - ssh.call( - "systemctl poweroff", - self.ssh_cancel_token - .as_ref() - .expect("Machine not initialized or spawned") - .clone(), - ) - .await?; + // Then shut the system down. Try several commands because some cloud images + // (notably Arch) log in as an unprivileged default user and plain + // `systemctl poweroff` can fail with "Access denied". + let shutdown_cmd = r#"sh -lc 'systemctl poweroff \ + || sudo -n systemctl poweroff \ + || sudo -n poweroff \ + || poweroff \ + || shutdown -h now \ + || sudo -n shutdown -h now'"#; + if let Err(e) = ssh + .call( + shutdown_cmd, + self.ssh_cancel_token + .as_ref() + .expect("Machine not initialized or spawned") + .clone(), + ) + .await + { + // During shutdown the SSH session can drop before the command fully returns. + // Keep going and rely on the QEMU process wait/cleanup below. + debug!( + "Guest shutdown command returned error during teardown: {}", + e + ); + } info!("🔌 Shutting down VM-{}", self.id); // Tell the QEMU handler it's now fine to wait for exit. @@ -590,6 +779,58 @@ impl Machine { self.cid, self.keypair.privkey_path, ); + // SSH reachability can be slow on first boot (cloud-init + sshd startup), especially on + // slower disks or under nested virtualization. + let ssh_timeout = if is_kvm_available() { + Duration::from_secs(180) + } else { + Duration::from_secs(300) + }; + + // Helper: read pid written by launch_qemu. + async fn read_pid(vmid: &str) -> Result { + let dirs = QleanDirs::new()?; + let pid_file_path = dirs.runs.join(vmid).join("qemu.pid"); + + // QEMU writes pid almost immediately after spawn; wait a short time to make cleanup reliable + // even on slower filesystems. + for _ in 0..50 { + if let Ok(pid_str) = tokio::fs::read_to_string(&pid_file_path).await + && let Ok(pid) = pid_str.trim().parse::() + { + return Ok(pid); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + bail!("Failed to read QEMU pid file at {:?}", pid_file_path); + } + + // Helper: terminate QEMU process best-effort. + async fn terminate_qemu(pid: u32) { + let _ = std::process::Command::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .output(); + + // Give it a moment to exit; then SIGKILL. + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + if !std::path::Path::new(&format!("/proc/{}", pid)).exists() { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let _ = std::process::Command::new("kill") + .arg("-9") + .arg(pid.to_string()) + .output(); + } + + // Create a fresh cancellation token per launch. + let launch_cancel = CancellationToken::new(); + self.ssh_cancel_token = Some(launch_cancel.clone()); + + self.qemu_should_exit.store(false, Ordering::SeqCst); let qemu_params = crate::qemu::QemuLaunchParams { cid: self.cid, image: self.image.to_owned(), @@ -597,62 +838,54 @@ impl Machine { vmid: self.id.to_owned(), is_init, mac_address: self.mac_address.to_owned(), - cancel_token: self - .ssh_cancel_token - .as_ref() - .expect("Machine not initialized or spawned") - .clone(), + cancel_token: launch_cancel.clone(), expected_to_exit: self.qemu_should_exit.clone(), }; - let kvm_available = KVM_AVAILABLE.get().copied().unwrap_or(false); - let ssh_timeout = if kvm_available { - Duration::from_secs(60) - } else { - // Give more time if KVM is not available - Duration::from_secs(180) - }; + let mut qemu_handle = tokio::spawn(launch_qemu(qemu_params)); + let pid = read_pid(&self.id).await?; + self.pid = Some(pid); - let qemu_handle = tokio::spawn(launch_qemu(qemu_params)); - let ssh_handle = tokio::spawn(connect_ssh( + let mut ssh_handle = tokio::spawn(connect_ssh( self.cid, ssh_timeout, self.keypair.to_owned(), - self.ssh_cancel_token - .as_ref() - .expect("Machine not initialized or spawned") - .clone(), + launch_cancel.clone(), + self.mac_address.to_owned(), )); - // Wait for SSH to complete, or abort SSH if QEMU errors - tokio::select! { - result = ssh_handle => { - // SSH completed, QEMU continues running + // Wait for SSH to complete, or abort SSH if QEMU errors. + let ssh_result = tokio::select! { + result = &mut ssh_handle => { + result.map_err(|e| anyhow::anyhow!("SSH task panicked: {e}"))? + } + result = &mut qemu_handle => { + // QEMU completed or errored, cancel SSH task. + launch_cancel.cancel(); match result { - Ok(Ok(session)) => { - self.ssh = Some(session); - let dirs = QleanDirs::new()?; - let runs_dir = dirs.runs; - let pid_file_path = runs_dir.join(&self.id).join("qemu.pid"); - let pid_str = tokio::fs::read_to_string(pid_file_path).await?; - self.pid = Some(pid_str.trim().parse()?); - } + Ok(Ok(())) => bail!("QEMU exited unexpectedly"), Ok(Err(e)) => bail!(e), - Err(e) => bail!("SSH task panicked: {}", e), + Err(e) => bail!("QEMU task error: {e}"), } } - result = qemu_handle => { - // QEMU completed or errored, cancel SSH task - self.ssh_cancel_token.as_ref().expect("Machine not initialized or spawned").cancel(); - match result { - Ok(Err(e)) => bail!(e), - Ok(Ok(())) => bail!("QEMU exited unexpectedly"), - Err(e) => bail!("QEMU task error: {}", e), + }; + + match ssh_result { + Ok(session) => { + self.ssh = Some(session); + Ok(()) + } + Err(e) => { + // Ensure QEMU is torn down to avoid orphan processes. + self.qemu_should_exit.store(true, Ordering::SeqCst); + launch_cancel.cancel(); + if let Some(pid) = self.pid { + terminate_qemu(pid).await; } + let _ = qemu_handle.await; + bail!(e) } } - - Ok(()) } } diff --git a/src/qemu.rs b/src/qemu.rs index ef239ed..030e001 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -15,13 +15,32 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, trace, warn}; use crate::{ - KVM_AVAILABLE, MachineConfig, + MachineConfig, + image::GuestArch, + is_kvm_available, machine::MachineImage, - utils::{CommandExt, QleanDirs}, + utils::{CommandExt, QLEAN_BRIDGE_NAME, QleanDirs}, }; const QEMU_TIMEOUT: Duration = Duration::from_secs(360 * 60); // 6 hours +fn qemu_system_program(arch: GuestArch) -> &'static str { + match arch { + GuestArch::Amd64 => "qemu-system-x86_64", + GuestArch::Aarch64 => "qemu-system-aarch64", + GuestArch::Riscv64 => "qemu-system-riscv64", + } +} + +fn host_arch() -> Option { + match std::env::consts::ARCH { + "x86_64" => Some(GuestArch::Amd64), + "aarch64" => Some(GuestArch::Aarch64), + "riscv64" => Some(GuestArch::Riscv64), + _ => None, + } +} + pub struct QemuLaunchParams { pub expected_to_exit: Arc, pub cid: u32, @@ -35,23 +54,21 @@ pub struct QemuLaunchParams { pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { // Prepare QEMU command - let mut qemu_cmd = tokio::process::Command::new("qemu-system-x86_64"); + let mut qemu_cmd = tokio::process::Command::new(qemu_system_program(params.image.arch)); + if params.image.arch == GuestArch::Amd64 { + // Decrease idle CPU usage on x86_64. + qemu_cmd.args(["-machine", "hpet=off"]); + } + + qemu_cmd.args([ + "-device", + &format!( + "vhost-vsock-pci,id=vhost-vsock-pci0,guest-cid={}", + params.cid + ), + ]); + qemu_cmd - // Decrease idle CPU usage - .args(["-machine", "hpet=off"]) - // SSH port forwarding - .args([ - "-device", - &format!( - "vhost-vsock-pci,id=vhost-vsock-pci0,guest-cid={}", - params.cid - ), - ]) - // Kernel - .args(["-kernel", params.image.kernel.to_str().unwrap()]) - .args(["-append", "rw root=/dev/vda1 console=ttyS0"]) - // Initrd - .args(["-initrd", params.image.initrd.to_str().unwrap()]) // Disk .args([ "-drive", @@ -61,42 +78,63 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { ), ]) // No GUI - .arg("-nographic") - // Network - .args(["-netdev", "bridge,id=net0,br=qlbr0"]) + .arg("-nographic"); + + qemu_cmd + .args([ + "-netdev", + &format!("bridge,id=net0,br={}", QLEAN_BRIDGE_NAME), + ]) .args([ "-device", &format!("virtio-net-pci,netdev=net0,mac={}", params.mac_address), - ]) - // Memory and CPUs + ]); + + // Memory and CPUs + qemu_cmd .args(["-m", ¶ms.config.mem.to_string()]) - .args(["-smp", ¶ms.config.core.to_string()]) - // Output redirection - .args(["-serial", "mon:stdio"]); + .args(["-smp", ¶ms.config.core.to_string()]); + // Output redirection + // We multiplex QEMU monitor + guest serial onto stdio AND tee it into a file under the run dir. + let dirs = QleanDirs::new()?; + let run_dir = dirs.runs.join(¶ms.vmid); + let serial_log = run_dir.join("serial.log"); + qemu_cmd + .args([ + "-chardev", + &format!( + "stdio,id=char0,mux=on,signal=off,logfile={},logappend=on", + serial_log.to_string_lossy() + ), + ]) + .args(["-serial", "chardev:char0"]) + .args(["-mon", "chardev=char0,mode=readline"]); if params.is_init { // Seed ISO qemu_cmd.args([ "-drive", &format!( - "file={},if=virtio,media=cdrom", + // Use an emulated CD-ROM device for maximum compatibility with NoCloud on Fedora/Arch. + // Some images do not reliably scan virtio-cdrom paths during early boot. + "file={},if=ide,media=cdrom,readonly=on", params.image.seed.to_str().unwrap() ), ]); } - let kvm_available = KVM_AVAILABLE.get().copied().unwrap_or(false); - if kvm_available { + if is_kvm_available() && host_arch() == Some(params.image.arch) { // KVM acceleration qemu_cmd.args(["-accel", "kvm"]).args(["-cpu", "host"]); } else { + qemu_cmd.args(["-accel", "tcg"]); warn!( - "KVM is not available on this host. QEMU will run without hardware acceleration, which may result in significantly reduced performance." + "KVM acceleration is unavailable for this host/guest architecture pair. Falling back to TCG emulation, which may be slower." ); } // Spawn QEMU process - debug!("Spawning QEMU with command:\n{:?}", qemu_cmd.to_string()); + debug!("QEMU command: {:?}", qemu_cmd.to_string()); let mut qemu_child = qemu_cmd .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -106,8 +144,7 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { // Store QEMU PID let pid = qemu_child.id().expect("failed to get QEMU PID"); - let dirs = QleanDirs::new()?; - let pid_file_path = dirs.runs.join(¶ms.vmid).join("qemu.pid"); + let pid_file_path = run_dir.join("qemu.pid"); tokio::fs::write(pid_file_path, pid.to_string()).await?; // Capture and log stdout @@ -116,7 +153,7 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { let reader = BufReader::new(stdout); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { - trace!("{}", strip_ansi_codes(&line)); + trace!("[qemu] {}", strip_ansi_codes(&line)); } }); @@ -126,7 +163,7 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { let reader = BufReader::new(stderr); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { - error!("{}", strip_ansi_codes(&line)); + error!("[qemu] {}", strip_ansi_codes(&line)); } }); diff --git a/src/ssh.rs b/src/ssh.rs index 595bc39..1cd7b9e 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -6,7 +6,7 @@ use std::{ time::Duration, }; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use russh::{ ChannelMsg, Disconnect, keys::{ @@ -17,11 +17,14 @@ use russh::{ use russh_sftp::{client::SftpSession, protocol::OpenFlags}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - time::Instant, + time::{Instant, sleep}, }; use tokio_util::sync::CancellationToken; use tokio_vsock::{VsockAddr, VsockStream}; -use tracing::{debug, error, info}; +use tracing::{debug, info}; + +const ERR_EACCES: i32 = 13; // Permission denied +const ERR_ENODEV: i32 = 19; // No such device #[derive(Clone, Debug)] pub struct PersistedSshKeypair { @@ -83,7 +86,7 @@ pub fn get_ssh_key(dir: &Path) -> Result { Ok(keypair) } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] struct SshClient {} // More SSH event handlers can be defined in this trait @@ -112,6 +115,7 @@ impl Session { /// Connect to an SSH server via vsock async fn connect( privkey: PrivateKey, + username: &str, cid: u32, port: u32, timeout: Duration, @@ -127,101 +131,121 @@ impl Session { let vsock_addr = VsockAddr::new(cid, port); let now = Instant::now(); - info!("🔑 Connecting via vsock"); - let mut session = loop { - // Check for cancellation - if cancel_token.is_cancelled() { - info!("SSH connection cancelled during connect loop"); - bail!("SSH connection cancelled"); - } - - tokio::time::sleep(Duration::from_millis(100)).await; - - // Establish vsock connection - let stream = match VsockStream::connect(vsock_addr).await { - Ok(stream) => stream, - Err(ref e) if e.raw_os_error() == Some(19) => { - // This is "No such device" but for some reason Rust doesn't have an IO - // ErrorKind for it. Meh. - if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" - ); - bail!("Timeout"); - } - continue; + let mut session = + loop { + // Check for cancellation + if cancel_token.is_cancelled() { + bail!("SSH connection cancelled"); } - Err(ref e) => match e.kind() { - ErrorKind::TimedOut - | ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset => { - if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" - ); - bail!("Timeout"); - } - continue; - } - e => { - error!("Unhandled error occurred: {e}"); - bail!("Unknown error"); - } - }, - }; - // Connect to SSH via vsock stream - match russh::client::connect_stream(config.clone(), stream, sh.clone()).await { - Ok(x) => break x, - Err(russh::Error::IO(ref e)) => { - match e.kind() { - // The VM is still booting at this point so we're just ignoring these errors - // for some time. - ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset => { + tokio::time::sleep(Duration::from_millis(100)).await; + + // Establish vsock connection + let connect_budget = timeout + .saturating_sub(now.elapsed()) + .max(Duration::from_millis(1)); + let stream = + match tokio::time::timeout(connect_budget, VsockStream::connect(vsock_addr)) + .await + { + Ok(Ok(stream)) => stream, + Err(_) => { + bail!("Timeout while connecting to VM via vsock."); + } + Ok(Err(ref e)) if e.raw_os_error() == Some(ERR_EACCES) => { + bail!("Permission denied while connecting via vsock: {e}\n"); + } + Ok(Err(ref e)) if e.raw_os_error() == Some(ERR_ENODEV) => { + // ENODEV is commonly observed while QEMU is still booting/initializing the vsock + // transport (e.g. the guest CID isn't ready yet). Treat it as transient and retry + // until the overall timeout is reached. + debug!("SSH vsock connect not ready yet (ENODEV): {e} (will retry)"); if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" - ); - bail!("Timeout"); + bail!("Timeout while connecting to VM via vsock.\n"); } + continue; } - e => { - error!("Unhandled error occurred: {e}"); - bail!("Unknown error"); + Ok(Err(ref e)) => match e.kind() { + ErrorKind::TimedOut + | ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::NetworkUnreachable + | ErrorKind::AddrNotAvailable => { + if now.elapsed() > timeout { + bail!("Timeout while connecting to VM via vsock."); + } + continue; + } + e => { + bail!("SSH vsock connect error: {e}"); + } + }, + }; + + // Connect to SSH via vsock stream + let handshake_budget = timeout + .saturating_sub(now.elapsed()) + .max(Duration::from_millis(1)); + match tokio::time::timeout( + handshake_budget, + russh::client::connect_stream(config.clone(), stream, sh.clone()), + ) + .await + { + Ok(Ok(x)) => break x, + Err(_) => { + bail!("Timeout establishing SSH handshake over vsock."); + } + Ok(Err(russh::Error::IO(ref e))) => { + match e.kind() { + // The VM is still booting at this point so we're just ignoring these errors + // for some time. + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::UnexpectedEof => { + if now.elapsed() > timeout { + bail!("Timeout establishing SSH handshake over vsock."); + } + } + e => { + bail!("SSH handshake error: {e}"); + } } } - } - Err(russh::Error::Disconnect) => { - if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" - ); - bail!("Timeout"); + Ok(Err(russh::Error::Disconnect)) => { + if now.elapsed() > timeout { + bail!("Timeout establishing SSH handshake over vsock."); + } + } + Ok(Err(e)) => { + bail!("SSH client error: {e}"); } } - Err(e) => { - error!("Unhandled error occurred: {e}"); - bail!("Unknown error"); - } - } - }; + }; debug!("Authenticating via SSH"); - - // use publickey authentication - let auth_res = session - .authenticate_publickey("root", PrivateKeyWithHashAlg::new(Arc::new(privkey), None)) - .await?; - + let auth_budget = timeout + .saturating_sub(now.elapsed()) + .max(Duration::from_secs(1)); + let auth_res = tokio::time::timeout( + auth_budget, + session.authenticate_publickey( + username, + PrivateKeyWithHashAlg::new(Arc::new(privkey), None), + ), + ) + .await + .with_context(|| format!("SSH authentication timed out for user {username}"))??; if !auth_res.success() { bail!("Authentication (with publickey) failed"); } - Ok(Self { session, sftp: None, }) } + // NOTE: TCP-based SSH fallback intentionally not supported. + // Reviewer feedback: avoid architecture changes that introduce non-vsock transports. /// Open an SFTP session over the existing SSH connection. async fn open_sftp(&mut self) -> Result { let channel = self.session.channel_open_session().await?; @@ -266,33 +290,32 @@ impl Session { bail!("SSH call cancelled"); } - // Handle one of the possible events: - tokio::select! { - // There's an event available on the session channel - Some(msg) = channel.wait() => { - match msg { - // Write data to the terminal - ChannelMsg::Data { ref data } => { - stdout.write_all(data).await?; - stdout.flush().await?; - } - ChannelMsg::ExtendedData { ref data, ext } => { - // ext == 1 means it's stderr content - // https://github.com/Eugeny/russh/discussions/258 - if ext == 1 { - stderr.write_all(data).await?; - stderr.flush().await?; - } - } - // The command has returned an exit code - ChannelMsg::ExitStatus { exit_status } => { - code = exit_status; - channel.eof().await?; - break; - } - _ => {} - } - }, + let Some(msg) = channel.wait().await else { + bail!( + "SSH channel closed before exit status for command: {}", + command + ); + }; + match msg { + // Write data to the terminal + ChannelMsg::Data { ref data } => { + stdout.write_all(data).await?; + stdout.flush().await?; + } + ChannelMsg::ExtendedData { ref data, ext: 1 } => { + // ext == 1 means it's stderr content + // https://github.com/Eugeny/russh/discussions/258 + stderr.write_all(data).await?; + stderr.flush().await?; + } + + // The command has returned an exit code + ChannelMsg::ExitStatus { exit_status } => { + code = exit_status; + channel.eof().await?; + break; + } + _ => {} } } Ok(code) @@ -318,31 +341,29 @@ impl Session { bail!("SSH call cancelled"); } - // Handle one of the possible events: - tokio::select! { - // There's an event available on the session channel - Some(msg) = channel.wait() => { - match msg { - // Write data to the buffer - ChannelMsg::Data { ref data } => { - stdout.extend_from_slice(data); - } - ChannelMsg::ExtendedData { ref data, ext } => { - // ext == 1 means it's stderr content - // https://github.com/Eugeny/russh/discussions/258 - if ext == 1 { - stderr.extend_from_slice(data); - } - } - // The command has returned an exit code - ChannelMsg::ExitStatus { exit_status } => { - code = exit_status; - channel.eof().await?; - break; - } - _ => {} - } - }, + let Some(msg) = channel.wait().await else { + bail!( + "SSH channel closed before exit status for command: {}", + command + ); + }; + match msg { + // Write data to the buffer + ChannelMsg::Data { ref data } => { + stdout.extend_from_slice(data); + } + ChannelMsg::ExtendedData { ref data, ext: 1 } => { + // ext == 1 means it's stderr content + // https://github.com/Eugeny/russh/discussions/258 + stderr.extend_from_slice(data); + } + // The command has returned an exit code + ChannelMsg::ExitStatus { exit_status } => { + code = exit_status; + channel.eof().await?; + break; + } + _ => {} } } Ok((code, stdout, stderr)) @@ -362,26 +383,60 @@ pub async fn connect_ssh( timeout: Duration, keypair: PersistedSshKeypair, cancel_token: CancellationToken, + _mac_address: String, ) -> Result { + if !std::path::Path::new("/dev/vhost-vsock").exists() { + bail!("/dev/vhost-vsock is missing. Qlean requires vhost-vsock for SSH (no TCP fallback).") + } + let privkey = PrivateKey::from_openssh(&keypair.privkey_str)?; + let deadline = Instant::now() + timeout; + let mut last_err: Option = None; - // Session is a wrapper around a russh client, defined down below. - let mut ssh = Session::connect(privkey, cid, 22, timeout, cancel_token.clone()).await?; - info!("✅ Connected"); + while Instant::now() < deadline { + if cancel_token.is_cancelled() { + bail!("SSH connection cancelled"); + } - // First we'll wait until the system has fully booted up. - let is_running_exitcode = ssh - .call( - "systemctl is-system-running --wait --quiet", + let remaining = deadline.saturating_duration_since(Instant::now()); + let per_attempt_timeout = Duration::from_secs(12) + .min(remaining.max(Duration::from_secs(1))) + .min(Duration::from_secs(25)); + + match Session::connect( + privkey.clone(), + "root", + cid, + 22, + per_attempt_timeout, cancel_token.clone(), ) - .await?; - debug!("systemctl is-system-running --wait exit code {is_running_exitcode}"); + .await + { + Ok(mut session) => { + info!("✅ Connected via vsock as root"); + + let ready_budget = deadline.saturating_duration_since(Instant::now()); + if ready_budget == Duration::ZERO { + bail!("SSH connection timed out"); + } + + let _ = + tokio::time::timeout(ready_budget, session.call("true", cancel_token.clone())) + .await + .context("SSH readiness probe timed out")??; - // Allow the --env option to work by allowing SSH to accept all sent environment variables. - // ssh.call("echo AcceptEnv * >> /etc/ssh/sshd_config").await?; + debug!("SSH command channel is ready"); + return Ok(session); + } + Err(e) => { + last_err = Some(e); + sleep(Duration::from_millis(250)).await; + } + } + } - Ok(ssh) + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Timeout establishing SSH over vsock."))) } impl Session { diff --git a/src/utils.rs b/src/utils.rs index 58a4fed..d2825bd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,6 +4,7 @@ use std::{ }; use anyhow::{Context, Result, bail}; + use dir_lock::DirLock; use directories::ProjectDirs; use rand::Rng; @@ -15,6 +16,11 @@ pub static HEX_ALPHABET: [char; 16] = [ ]; pub const VIRSH_CONNECTION_URI: &str = "qemu:///system"; +pub const QLEAN_BRIDGE_NAME: &str = "qlbr0"; + +// NOTE: `derive_mac()` was previously used by an experimental multi-NIC TCP hostfwd +// path. The current implementation is vsock-only, so we avoid keeping unused code +// that triggers `dead_code` warnings. pub struct QleanDirs { pub base: PathBuf, @@ -123,16 +129,14 @@ impl CommandExt for tokio::process::Command { } } +/// Ensure host prerequisites for running virtual machines. pub async fn ensure_prerequisites() -> Result<()> { check_command_available("qemu-system-x86_64").await?; check_command_available("qemu-img").await?; - check_command_available("sha256sum").await?; - check_command_available("sha512sum").await?; check_command_available("xorriso").await?; - check_command_available("guestfish").await?; - check_command_available("virt-copy-out").await?; check_command_available("virsh").await?; ensure_network().await?; + ensure_vsock()?; Ok(()) } @@ -145,6 +149,15 @@ async fn check_command_available(cmd: &str) -> Result<()> { Ok(()) } +pub fn ensure_vsock() -> Result<()> { + if !Path::new("/dev/vhost-vsock").exists() { + bail!( + "`/dev/vhost-vsock` is missing. Qlean requires vhost-vsock for SSH. Please ensure it is available and try again." + ); + } + Ok(()) +} + async fn ensure_network() -> Result<()> { let output = tokio::process::Command::new("virsh") .arg("-c") @@ -155,8 +168,8 @@ async fn ensure_network() -> Result<()> { .output() .await .context("failed to execute virsh to check qlean network")?; - let stdout = String::from_utf8_lossy(&output.stdout); - let all_networks = stdout.lines().collect::>(); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let all_networks = stdout.lines().map(str::to_owned).collect::>(); let net_exists = all_networks.contains("qlean"); let output = tokio::process::Command::new("virsh") @@ -167,8 +180,8 @@ async fn ensure_network() -> Result<()> { .output() .await .context("failed to execute virsh to check qlean network")?; - let stdout = String::from_utf8_lossy(&output.stdout); - let active_networks = stdout.lines().collect::>(); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let active_networks = stdout.lines().map(str::to_owned).collect::>(); let net_active = active_networks.contains("qlean"); if !net_exists { @@ -241,3 +254,7 @@ pub fn gen_random_mac() -> String { bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5] ) } + +pub fn qlean_user_agent() -> &'static str { + "qlean/0.3.0" +} diff --git a/tests/common.rs b/tests/common.rs deleted file mode 100644 index aeccf82..0000000 --- a/tests/common.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::sync::Once; -use tracing_subscriber::{EnvFilter, fmt::time::LocalTime}; - -pub fn tracing_subscriber_init() { - static INIT: Once = Once::new(); - INIT.call_once(|| { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .with_timer(LocalTime::rfc_3339()) - .init(); - }); -} diff --git a/tests/custom_image.rs b/tests/custom_image.rs deleted file mode 100644 index 1fad1e1..0000000 --- a/tests/custom_image.rs +++ /dev/null @@ -1,130 +0,0 @@ -use anyhow::Result; -use qlean::{CustomImageConfig, ImageSource, ShaType, create_custom_image}; -use serial_test::serial; -use std::path::PathBuf; - -mod common; -use common::tracing_subscriber_init; - -// --------------------------------------------------------------------------- -// Unit tests for CustomImageConfig -// --------------------------------------------------------------------------- - -#[test] -fn test_custom_image_config_with_preextracted_serde() { - let config = CustomImageConfig { - image_source: ImageSource::Url("https://example.com/image.qcow2".to_string()), - image_hash: "abcdef123456".to_string(), - image_hash_type: ShaType::Sha256, - kernel_source: Some(ImageSource::Url("https://example.com/vmlinuz".to_string())), - kernel_hash: Some("kernel789".to_string()), - initrd_source: Some(ImageSource::Url("https://example.com/initrd".to_string())), - initrd_hash: Some("initrd012".to_string()), - }; - - let json = serde_json::to_string(&config).unwrap(); - let decoded: CustomImageConfig = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.image_hash, "abcdef123456"); - assert_eq!(decoded.kernel_hash, Some("kernel789".to_string())); - assert_eq!(decoded.initrd_hash, Some("initrd012".to_string())); -} - -#[test] -fn test_custom_image_config_url_serde() { - let config = CustomImageConfig { - image_source: ImageSource::Url("https://example.com/image.qcow2".to_string()), - image_hash: "abc123".to_string(), - image_hash_type: ShaType::Sha256, - kernel_source: None, - kernel_hash: None, - initrd_source: None, - initrd_hash: None, - }; - - let json = serde_json::to_string(&config).unwrap(); - let decoded: CustomImageConfig = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.image_hash, "abc123"); - // Test that None values are properly serialized/deserialized - assert!(decoded.kernel_source.is_none()); -} - -#[test] -fn test_custom_image_config_local_path_serde() { - let config = CustomImageConfig { - image_source: ImageSource::LocalPath(PathBuf::from("/path/to/image.qcow2")), - image_hash: "def456".to_string(), - image_hash_type: ShaType::Sha512, - kernel_source: Some(ImageSource::LocalPath(PathBuf::from("/path/to/vmlinuz"))), - kernel_hash: Some("kernelhash".to_string()), - initrd_source: Some(ImageSource::LocalPath(PathBuf::from("/path/to/initrd"))), - initrd_hash: Some("initrdhash".to_string()), - }; - - let json = serde_json::to_string(&config).unwrap(); - let decoded: CustomImageConfig = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.image_hash, "def456"); - match decoded.kernel_source.unwrap() { - ImageSource::LocalPath(p) => assert_eq!(p, PathBuf::from("/path/to/vmlinuz")), - _ => panic!("Expected LocalPath"), - } -} - -// --------------------------------------------------------------------------- -// Error handling tests -// --------------------------------------------------------------------------- - -#[tokio::test] -#[serial] -async fn test_custom_image_nonexistent_local_path() -> Result<()> { - tracing_subscriber_init(); - - let config = CustomImageConfig { - image_source: ImageSource::LocalPath(PathBuf::from("/nonexistent/image.qcow2")), - image_hash: "fakehash".to_string(), - image_hash_type: ShaType::Sha256, - kernel_source: None, - kernel_hash: None, - initrd_source: None, - initrd_hash: None, - }; - - let result = create_custom_image("test-nonexistent", config).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("does not exist")); - - Ok(()) -} - -#[tokio::test] -#[serial] -async fn test_custom_image_hash_mismatch() -> Result<()> { - tracing_subscriber_init(); - - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - { - use std::io::Write; - let mut f = std::fs::File::create(&path)?; - f.write_all(b"test content")?; - } - - let config = CustomImageConfig { - image_source: ImageSource::LocalPath(path), - image_hash: "wronghash123".to_string(), - image_hash_type: ShaType::Sha256, - kernel_source: None, - kernel_hash: None, - initrd_source: None, - initrd_hash: None, - }; - - let result = create_custom_image("test-hash-mismatch", config).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("hash mismatch")); - - Ok(()) -} diff --git a/tests/machine_pool.rs b/tests/machine_pool.rs index a0e49f3..4567244 100644 --- a/tests/machine_pool.rs +++ b/tests/machine_pool.rs @@ -1,16 +1,18 @@ use anyhow::Result; -use qlean::{Distro, MachineConfig, create_image, with_pool}; +use qlean::{Distro, Image, ImageConfig, MachineConfig, with_pool}; +use serial_test::serial; -mod common; -use common::tracing_subscriber_init; +mod utils; +use utils::tracing_subscriber_init; #[tokio::test] +#[serial] async fn test_ping() -> Result<()> { tracing_subscriber_init(); with_pool(|pool| { Box::pin(async { - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); pool.add("alice".to_string(), &image, &config).await?; @@ -36,12 +38,13 @@ async fn test_ping() -> Result<()> { } #[tokio::test] +#[serial] async fn test_concurrency() -> Result<()> { tracing_subscriber_init(); with_pool(|pool| { Box::pin(async { - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); pool.add("vm1".to_string(), &image, &config).await?; diff --git a/tests/muti_distro.rs b/tests/muti_distro.rs new file mode 100644 index 0000000..8bf596a --- /dev/null +++ b/tests/muti_distro.rs @@ -0,0 +1,74 @@ +use std::str; + +use anyhow::Result; +use qlean::{Distro, Image, ImageConfig, MachineConfig, with_machine}; +use serial_test::serial; + +mod utils; +use utils::tracing_subscriber_init; + +#[tokio::test] +#[serial] +async fn test_ubuntu_image() -> Result<()> { + tracing_subscriber_init(); + + let image = Image::new(ImageConfig::default().with_distro(Distro::Ubuntu)).await?; + let config = MachineConfig::default(); + + with_machine(&image, &config, |vm| { + Box::pin(async { + let result = vm.exec(". /etc/os-release && echo $ID").await?; + assert!(result.status.success()); + assert_eq!(str::from_utf8(&result.stdout)?.trim(), "ubuntu"); + + Ok(()) + }) + }) + .await?; + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn test_fedora_image() -> Result<()> { + tracing_subscriber_init(); + + let image = Image::new(ImageConfig::default().with_distro(Distro::Fedora)).await?; + let config = MachineConfig::default(); + + with_machine(&image, &config, |vm| { + Box::pin(async { + let result = vm.exec(". /etc/os-release && echo $ID").await?; + assert!(result.status.success()); + assert_eq!(str::from_utf8(&result.stdout)?.trim(), "fedora"); + + Ok(()) + }) + }) + .await?; + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn test_arch_image() -> Result<()> { + tracing_subscriber_init(); + + let image = Image::new(ImageConfig::default().with_distro(Distro::Arch)).await?; + let config = MachineConfig::default(); + + with_machine(&image, &config, |vm| { + Box::pin(async { + let result = vm.exec(". /etc/os-release && echo $ID").await?; + assert!(result.status.success()); + assert_eq!(str::from_utf8(&result.stdout)?.trim(), "arch"); + + Ok(()) + }) + }) + .await?; + + Ok(()) +} diff --git a/tests/single_machine.rs b/tests/single_machine.rs index 76fea78..5f09ef6 100644 --- a/tests/single_machine.rs +++ b/tests/single_machine.rs @@ -5,16 +5,18 @@ use std::{ }; use anyhow::Result; -use qlean::{Distro, MachineConfig, create_image, with_machine}; +use qlean::{Distro, Image, ImageConfig, MachineConfig, with_machine}; +use serial_test::serial; -mod common; -use common::tracing_subscriber_init; +mod utils; +use utils::tracing_subscriber_init; #[tokio::test] +#[serial] async fn hello() -> Result<()> { tracing_subscriber_init(); - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); with_machine(&image, &config, |vm| { @@ -33,10 +35,11 @@ async fn hello() -> Result<()> { } #[tokio::test] +#[serial] async fn test_file_transfer() -> Result<()> { tracing_subscriber_init(); - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); with_machine(&image, &config, |vm| { @@ -127,10 +130,11 @@ async fn test_file_transfer() -> Result<()> { } #[tokio::test] +#[serial] async fn test_file_operation() -> Result<()> { tracing_subscriber_init(); - let image = create_image(Distro::Debian, "debian-13-generic-amd64").await?; + let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); with_machine(&image, &config, |vm| { diff --git a/tests/streaming_hash.rs b/tests/streaming_hash.rs deleted file mode 100644 index bab804e..0000000 --- a/tests/streaming_hash.rs +++ /dev/null @@ -1,153 +0,0 @@ -use anyhow::Result; -use qlean::{compute_sha256_streaming, compute_sha512_streaming, get_sha256, get_sha512}; -use serial_test::serial; - -mod common; -use common::tracing_subscriber_init; - -// --------------------------------------------------------------------------- -// Correctness tests: streaming hash must match shell commands -// --------------------------------------------------------------------------- - -#[tokio::test] -#[serial] -async fn test_streaming_sha256_matches_shell() -> Result<()> { - tracing_subscriber_init(); - - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - { - use std::io::Write; - let mut f = std::fs::File::create(&path)?; - f.write_all(b"streaming sha256 correctness check")?; - } - - let shell_result = get_sha256(&path).await?; - let stream_result = compute_sha256_streaming(&path).await?; - - assert_eq!( - shell_result, stream_result, - "streaming SHA-256 must match shell command output" - ); - - Ok(()) -} - -#[tokio::test] -#[serial] -async fn test_streaming_sha512_matches_shell() -> Result<()> { - tracing_subscriber_init(); - - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - { - use std::io::Write; - let mut f = std::fs::File::create(&path)?; - f.write_all(b"streaming sha512 correctness check")?; - } - - let shell_result = get_sha512(&path).await?; - let stream_result = compute_sha512_streaming(&path).await?; - - assert_eq!( - shell_result, stream_result, - "streaming SHA-512 must match shell command output" - ); - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Edge case tests -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_streaming_sha256_empty_file() -> Result<()> { - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - let hash = compute_sha256_streaming(&path).await?; - - // SHA-256 of empty file (well-known constant) - assert_eq!( - hash, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ); - - Ok(()) -} - -#[tokio::test] -async fn test_streaming_sha256_small_file() -> Result<()> { - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - { - use std::io::Write; - let mut f = std::fs::File::create(&path)?; - f.write_all(b"hello world")?; - } - - let hash = compute_sha256_streaming(&path).await?; - - // SHA-256 of "hello world" - assert_eq!( - hash, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - ); - - Ok(()) -} - -#[tokio::test] -async fn test_streaming_sha512_known_value() -> Result<()> { - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - { - use std::io::Write; - let mut f = std::fs::File::create(&path)?; - f.write_all(b"The quick brown fox jumps over the lazy dog")?; - } - - let hash = compute_sha512_streaming(&path).await?; - - // SHA-512 of "The quick brown fox jumps over the lazy dog" - assert_eq!( - hash, - "07e547d9586f6a73f73fbac0435ed76951218fb7d0c8d788a309d785436bbb642e93a252a954f23912547d1e8a3b5ed6e1bfd7097821233fa0538f3db854fee6" - ); - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Large file tests -// --------------------------------------------------------------------------- - -#[tokio::test] -#[serial] -async fn test_streaming_sha256_10mb_file() -> Result<()> { - tracing_subscriber_init(); - - let tmp = tempfile::NamedTempFile::new()?; - let path = tmp.path().to_path_buf(); - - { - use std::io::Write; - let mut f = std::fs::File::create(&path)?; - let chunk = vec![0xABu8; 1024 * 1024]; // 1 MB of 0xAB - for _ in 0..10 { - f.write_all(&chunk)?; - } - } - - let shell = get_sha256(&path).await?; - let stream = compute_sha256_streaming(&path).await?; - - assert_eq!(shell, stream, "10MB file: streaming must match shell"); - - Ok(()) -} diff --git a/tests/ubuntu_image.rs b/tests/ubuntu_image.rs deleted file mode 100644 index 8cfa456..0000000 --- a/tests/ubuntu_image.rs +++ /dev/null @@ -1,27 +0,0 @@ -use anyhow::Result; -use qlean::{Distro, create_image}; -use serial_test::serial; - -mod common; -use common::tracing_subscriber_init; - -#[tokio::test] -#[serial] -#[ignore] -async fn test_ubuntu_image_creation() -> Result<()> { - tracing_subscriber_init(); - - // Ubuntu uses pre-extracted kernel/initrd - no guestfish needed! - let image = create_image(Distro::Ubuntu, "ubuntu-noble-cloudimg").await?; - - assert!(image.path().exists(), "qcow2 image must exist"); - assert!(image.kernel().exists(), "kernel must exist"); - assert!(image.initrd().exists(), "initrd must exist"); - - println!("✅ Ubuntu image created successfully!"); - println!(" Image: {}", image.path().display()); - println!(" Kernel: {}", image.kernel().display()); - println!(" Initrd: {}", image.initrd().display()); - - Ok(()) -} diff --git a/tests/utils.rs b/tests/utils.rs new file mode 100644 index 0000000..9afa28a --- /dev/null +++ b/tests/utils.rs @@ -0,0 +1,29 @@ +use std::sync::Once; + +use tracing_indicatif::IndicatifLayer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt::time::LocalTime}; + +/// Initialize a global tracing subscriber for integration tests. +/// +/// Multiple integration test crates may attempt to install a global subscriber. +/// We use `try_init()` to avoid panics if one is already set. +pub fn tracing_subscriber_init() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,qlean=info")); + let indicatif_layer = IndicatifLayer::new(); + tracing_subscriber::registry() + .with(env_filter) + .with( + tracing_subscriber::fmt::layer() + .with_timer(LocalTime::rfc_3339()) + .with_writer(indicatif_layer.get_stderr_writer()), + ) + .with(indicatif_layer) + .try_init() + .ok(); + }); +}