diff --git a/Cargo.lock b/Cargo.lock index 5cb59f0..6b91512 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2247,6 +2247,7 @@ dependencies = [ "tokio-fd", "tokio-util", "tokio-vsock", + "toml", "tracing", "tracing-subscriber", "walkdir", @@ -2907,6 +2908,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_yml" version = "0.0.12" @@ -3405,6 +3415,47 @@ dependencies = [ "vsock", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" @@ -4217,6 +4268,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index c143927..152c9a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ russh-sftp = "2.1.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" serde_yml = "0.0.12" +toml = "0.8.19" sha2 = "0.10" shell-escape = "0.1.5" termion = "4.0.6" diff --git a/qlean-images.toml b/qlean-images.toml new file mode 100644 index 0000000..4f1e6ac --- /dev/null +++ b/qlean-images.toml @@ -0,0 +1,27 @@ +# Default official image sources used by Qlean integration tests. +# Users may edit these URLs/checksum files directly to point to their preferred +# official mirror or pinned image release. + +[debian] +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 = "Sha512" + +[ubuntu] +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 = "Sha256" + +[fedora] +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 = "Sha256" + +[arch] +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 = "Sha256" diff --git a/scripts/setup-host-prereqs.sh b/scripts/setup-host-prereqs.sh new file mode 100644 index 0000000..7b9047d --- /dev/null +++ b/scripts/setup-host-prereqs.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Qlean host prerequisites helper. +# +# This script configures the explicit host prerequisites required by Qlean: +# - qemu-bridge-helper permissions for the Qlean bridge +# - the libvirt 'qlean' network and qlbr0 bridge +# - host libguestfs-tools installation/runtime verification +# +# Qlean no longer provisions or discovers fallback paths at runtime; run this +# script once before using distro image creation or E2E tests. + +BRIDGE_NAME="${QLEAN_BRIDGE_NAME:-qlbr0}" +VIRSH_URI="qemu:///system" + +need_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: Please run as root (e.g. sudo $0)" >&2 + exit 1 + fi +} + +ensure_bridge_conf() { + mkdir -p /etc/qemu + local conf=/etc/qemu/bridge.conf + + if [[ -f "$conf" ]]; then + if grep -qE "^allow[[:space:]]+all$" "$conf"; then + echo "OK: $conf already allows all bridges" + chmod 0644 "$conf" + return + fi + if grep -qE "^allow[[:space:]]+${BRIDGE_NAME}$" "$conf"; then + echo "OK: $conf already allows ${BRIDGE_NAME}" + chmod 0644 "$conf" + return + fi + fi + + echo "allow ${BRIDGE_NAME}" >> "$conf" + chmod 0644 "$conf" + echo "Wrote: allow ${BRIDGE_NAME} -> $conf" +} + +find_qemu_bridge_helper() { + local candidates=( + /usr/lib/qemu/qemu-bridge-helper + /usr/libexec/qemu-bridge-helper + /usr/lib64/qemu/qemu-bridge-helper + ) + + for p in "${candidates[@]}"; do + if [[ -x "$p" ]]; then + echo "$p" + return 0 + fi + done + + if command -v qemu-bridge-helper >/dev/null 2>&1; then + command -v qemu-bridge-helper + return 0 + fi + + return 1 +} + +ensure_bridge_helper_caps() { + local helper + if ! helper="$(find_qemu_bridge_helper)"; then + echo "WARN: qemu-bridge-helper not found. Install QEMU first." >&2 + return + fi + + if command -v setcap >/dev/null 2>&1; then + chmod u-s "$helper" || true + setcap cap_net_admin+ep "$helper" + + echo "OK: setcap cap_net_admin+ep $helper" + if command -v getcap >/dev/null 2>&1; then + getcap "$helper" || true + fi + else + echo "WARN: setcap not found. On Debian/Ubuntu install libcap2-bin." >&2 + fi +} + +ensure_qlean_network() { + if ! command -v virsh >/dev/null 2>&1; then + echo "ERROR: virsh not found. Install libvirt-clients/libvirt-daemon-system first." >&2 + exit 1 + fi + + if ! virsh -c "$VIRSH_URI" net-info qlean >/dev/null 2>&1; then + local xml + xml=$(mktemp) + cat > "$xml" < + qlean + + + + + + + + +EOF + echo "INFO: defining libvirt network qlean" + virsh -c "$VIRSH_URI" net-define "$xml" + rm -f "$xml" + else + echo "OK: libvirt network qlean already defined" + fi + + echo "INFO: ensuring libvirt network qlean is active" + virsh -c "$VIRSH_URI" net-start qlean >/dev/null 2>&1 || true + virsh -c "$VIRSH_URI" net-autostart qlean >/dev/null 2>&1 || true +} + +maybe_install_guestfs_tools_ubuntu() { + if ! command -v apt-get >/dev/null 2>&1; then + return + fi + + if command -v guestfish >/dev/null 2>&1 \ + && command -v virt-copy-out >/dev/null 2>&1 \ + && command -v libguestfs-test-tool >/dev/null 2>&1; then + echo "OK: libguestfs tools already installed" + return + fi + + echo "INFO: Installing libguestfs tools (guestfish, virt-copy-out) via apt-get" + apt-get update -y + apt-get install -y libguestfs-tools +} + +verify_guestfs_runtime() { + if ! command -v libguestfs-test-tool >/dev/null 2>&1; then + echo "WARN: libguestfs-test-tool not found after installation. Check your libguestfs-tools package." >&2 + return + fi + + echo "INFO: Verifying host libguestfs runtime (LIBGUESTFS_BACKEND=direct libguestfs-test-tool)" + if ! LIBGUESTFS_BACKEND=direct libguestfs-test-tool; then + echo "ERROR: libguestfs-test-tool failed. Fix the host libguestfs-tools installation before using Qlean image extraction." >&2 + exit 1 + fi +} + +need_root +ensure_bridge_conf +ensure_bridge_helper_caps +ensure_qlean_network +maybe_install_guestfs_tools_ubuntu +verify_guestfs_runtime + +echo "DONE" diff --git a/src/image.rs b/src/image.rs index e4f75d3..c27aa68 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,13 +1,26 @@ -use std::path::{Path, PathBuf}; +use std::{ + ffi::OsStr, + fs, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result, bail}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha512}; -use tokio::{fs::File, io::AsyncWriteExt}; -use tracing::debug; +use tokio::{ + fs::File, + io::AsyncWriteExt, + time::{Duration, timeout}, +}; +use tracing::{debug, info}; -use crate::utils::QleanDirs; +use crate::utils::{QleanDirs, ensure_extraction_prerequisites}; + +fn default_root_arg() -> String { + "root=/dev/vda1".to_string() +} pub trait ImageAction { /// Download the image from remote source @@ -27,6 +40,8 @@ pub struct ImageMeta { pub path: PathBuf, pub kernel: PathBuf, pub initrd: PathBuf, + #[serde(default = "default_root_arg")] + pub root_arg: String, #[serde(skip)] pub vendor: A, pub checksum: ShaSum, @@ -41,7 +56,7 @@ pub enum Distro { Custom, } -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] pub enum ShaType { Sha256, Sha512, @@ -54,7 +69,7 @@ pub struct ShaSum { } /// 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), @@ -62,43 +77,177 @@ pub enum ImageSource { /// 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)] +/// 2. Image + pre-extracted kernel/initrd +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 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) + // Optional: pre-extracted kernel and initrd (avoids guestfish extraction) pub kernel_source: Option, pub kernel_hash: Option, pub initrd_source: Option, pub initrd_hash: Option, } -/// Parses SHA512SUMS format and returns the hash for an exact filename match. -/// -/// # Arguments -/// * `checksums_text` - The content of a SHA512SUMS file -/// * `filename` - The exact filename to search for (e.g., "debian-13-generic-amd64.qcow2") +/// 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 +} + +/// Parse a checksum file and return the hash for a given filename. /// -/// # 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| { - let mut parts = line.split_whitespace(); - let hash = parts.next()?; - let fname = parts.next()?; - - (fname == filename).then(|| hash.to_string()) +/// Supports common formats: +/// 1) " " (including "*filename" and "./filename") +/// 2) "SHA256 () = " / "SHA512 () = " +pub fn find_hash_for_file(checksums_text: &str, filename: &str) -> Option { + let mut parts = checksums_text.split_whitespace(); + while let Some(hash) = parts.next() { + let Some(fname) = parts.next() else { break }; + if checksum_name_matches(fname, filename) { + return Some(hash.to_string()); + } + } + + for line in checksums_text.lines() { + let line = line.trim(); + 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()); + } + } + } + + None +} + +const IMAGE_SOURCES_CONFIG_PATH: &str = "qlean-images.toml"; + +#[derive(Debug, Deserialize)] +struct ImageSourcesConfig { + debian: RemoteImageConfig, + ubuntu: RemoteImageConfig, + fedora: RemoteImageConfig, + arch: RemoteImageConfig, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +struct RemoteImageConfig { + image_url: String, + checksum_url: String, + checksum_entry: String, + checksum_type: ShaType, +} + +impl ImageSourcesConfig { + fn for_distro(&self, distro: Distro) -> Result<&RemoteImageConfig> { + match distro { + Distro::Debian => Ok(&self.debian), + Distro::Ubuntu => Ok(&self.ubuntu), + Distro::Fedora => Ok(&self.fedora), + Distro::Arch => Ok(&self.arch), + Distro::Custom => bail!("custom images do not use qlean-images.toml"), + } + } +} + +fn image_sources_config_path() -> PathBuf { + PathBuf::from(IMAGE_SOURCES_CONFIG_PATH) +} + +async fn load_image_sources_config() -> Result { + let path = image_sources_config_path(); + let content = tokio::fs::read_to_string(&path) + .await + .with_context(|| { + format!( + "failed to read image source config at {}. Copy or edit qlean-images.toml before creating distro images", + path.display() + ) + })?; + + toml::from_str(&content) + .with_context(|| format!("failed to parse TOML from {}", path.display())) +} + +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/0.2 (image-fetch)") + .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); + + resp.text() + .await + .with_context(|| format!("failed reading body from {}", url)) +} + +async fn fetch_expected_hash(config: &RemoteImageConfig) -> Result { + let checksums_text = fetch_text(&config.checksum_url) + .await + .with_context(|| format!("failed to fetch checksum file from {}", config.checksum_url))?; + + find_hash_for_file(&checksums_text, &config.checksum_entry).with_context(|| { + format!( + "checksum file {} did not contain an entry for {}", + config.checksum_url, config.checksum_entry + ) }) } +async fn download_remote_image(name: &str, distro: Distro) -> Result<()> { + let dirs = QleanDirs::new()?; + let image_path = dirs.images.join(name).join(format!("{}.qcow2", name)); + + let sources = load_image_sources_config().await?; + let config = sources.for_distro(distro)?; + let expected_hash = fetch_expected_hash(config).await?; + + materialize_source_with_hash( + &ImageSource::Url(config.image_url.clone()), + &image_path, + &expected_hash, + config.checksum_type.clone(), + ) + .await?; + + Ok(()) +} + // --------------------------------------------------------------------------- // Streaming hash functions - optimized for release mode performance // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + /// 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 { @@ -158,29 +307,88 @@ pub async fn compute_sha512_streaming(path: &Path) -> Result { .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( +/// Download a remote file and compute its hash in a single pass. +async fn stream_download_with_hash( url: &str, dest_path: &PathBuf, hash_type: ShaType, ) -> Result { + let tmp_path = dest_path.with_extension("part"); + debug!("Downloading {} to {}", url, dest_path.display()); - let response = reqwest::get(url) + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(20)) + .user_agent("qlean/0.2 (image-download)") + .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(total) = total_size { + info!("Remote size: {} MiB ({})", total / (1024 * 1024), url); + } + + 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 mut last_report: u64 = 0; + let report_step: u64 = 8 * 1024 * 1024; + let mut last_report_at = std::time::Instant::now(); let hash = match hash_type { ShaType::Sha256 => { let mut h = Sha256::new(); - while let Some(chunk) = stream.next().await { + 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; + let now = std::time::Instant::now(); + if downloaded - last_report >= report_step + || (downloaded > 0 + && now.duration_since(last_report_at) >= std::time::Duration::from_secs(10)) + { + last_report = downloaded; + last_report_at = now; + if let Some(total) = total_size { + info!( + "Download progress: {}/{} MiB ({})", + downloaded / (1024 * 1024), + total / (1024 * 1024), + url + ); + } else { + info!( + "Download progress: {} MiB ({})", + downloaded / (1024 * 1024), + url + ); + } + } h.update(&chunk); file.write_all(&chunk) .await @@ -190,8 +398,37 @@ pub async fn download_with_hash( } ShaType::Sha512 => { let mut h = Sha512::new(); - while let Some(chunk) = stream.next().await { + 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; + let now = std::time::Instant::now(); + if downloaded - last_report >= report_step + || (downloaded > 0 + && now.duration_since(last_report_at) >= std::time::Duration::from_secs(10)) + { + last_report = downloaded; + last_report_at = now; + if let Some(total) = total_size { + info!( + "Download progress: {}/{} MiB ({})", + downloaded / (1024 * 1024), + total / (1024 * 1024), + url + ); + } else { + info!( + "Download progress: {} MiB ({})", + downloaded / (1024 * 1024), + url + ); + } + } h.update(&chunk); file.write_all(&chunk) .await @@ -202,11 +439,27 @@ pub async fn download_with_hash( }; 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 complete: {} MiB ({})", + downloaded / (1024 * 1024), + url + ); Ok(hash) } -/// Download or copy file from ImageSource with hash verification -async fn download_or_copy_with_hash( +/// Materialize a source file into `dest` and verify it against the expected hash. +async fn materialize_source_with_hash( source: &ImageSource, dest: &PathBuf, expected_hash: &str, @@ -214,9 +467,21 @@ async fn download_or_copy_with_hash( ) -> Result<()> { match source { ImageSource::Url(url) => { - let computed = download_with_hash(url, dest, hash_type).await?; + if dest.exists() { + let existing = match &hash_type { + ShaType::Sha256 => compute_sha256_streaming(dest).await, + ShaType::Sha512 => compute_sha512_streaming(dest).await, + }; + if let Ok(h) = existing + && h.eq_ignore_ascii_case(expected_hash) + { + return Ok(()); + } + } + + let computed = stream_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 @@ -232,7 +497,7 @@ async fn download_or_copy_with_hash( }; anyhow::ensure!( - computed.to_lowercase() == expected_hash.to_lowercase(), + computed.eq_ignore_ascii_case(expected_hash), "hash mismatch: expected {}, got {}", expected_hash, computed @@ -261,12 +526,20 @@ impl ImageMeta { tokio::fs::create_dir_all(&image_dir).await?; let distro_action = A::default(); + let distro = distro_action.distro(); distro_action.download(name).await?; - let (kernel, initrd) = distro_action.extract(name).await?; let image_path = image_dir.join(format!("{}.qcow2", name)); + let (kernel, initrd) = distro_action.extract(name).await?; let checksum_path = image_dir.join("checksums"); + let root_arg = match distro { + Distro::Ubuntu => detect_root_arg(&image_path) + .await + .unwrap_or_else(|_| default_root_arg()), + Distro::Debian | Distro::Fedora | Distro::Arch => detect_root_arg(&image_path).await?, + Distro::Custom => unreachable!("custom images use create_with_action()"), + }; let checksum = ShaSum { path: checksum_path, sha_type: ShaType::Sha512, @@ -275,6 +548,7 @@ impl ImageMeta { path: image_path, kernel, initrd, + root_arg, checksum, name: name.to_string(), vendor: distro_action, @@ -294,9 +568,50 @@ 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 mut image: ImageMeta = serde_json::from_str(&json_content) .with_context(|| format!("failed to parse JSON from {}", json_path.display()))?; + // Older caches may contain a `root=` token with a trailing ':' (e.g. `root=/dev/vda3:`), + // which causes direct-kernel boot to hang forever waiting for a non-existent device. + // Sanitize on load so users don't have to manually delete their image cache. + image.root_arg = image + .root_arg + .split_whitespace() + .map(|t| { + if let Some(rest) = t.strip_prefix("root=") { + let clean = rest.trim_end_matches(':'); + format!("root={clean}") + } else { + t.to_string() + } + }) + .collect::>() + .join(" "); + + let kernel_ok = image.kernel.exists() + && !image + .kernel + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with(".unavailable")) + .unwrap_or(false) + && std::fs::metadata(&image.kernel) + .map(|m| m.len() > 0) + .unwrap_or(false); + let initrd_ok = image.initrd.exists() + && !image + .initrd + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with(".unavailable")) + .unwrap_or(false) + && std::fs::metadata(&image.initrd) + .map(|m| m.len() > 0) + .unwrap_or(false); + if !kernel_ok || !initrd_ok { + bail!("cached image is missing valid kernel/initrd artifacts; recreate is required"); + } + let checksum_dir = dirs.images.join(name); let checksum_command = match image.checksum.sha_type { ShaType::Sha256 => "sha256sum", @@ -321,8 +636,11 @@ impl ImageMeta { Ok(image) } +} - /// Save image metadata to disk using streaming hash +// Special create method for Custom images (non-Default trait) +impl ImageMeta { + /// 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,7 +652,7 @@ 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) + // 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?, @@ -380,10 +698,7 @@ impl ImageMeta { 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); @@ -398,9 +713,12 @@ impl ImageMeta { action.download(name).await?; - let (kernel, initrd) = action.extract(name).await?; let image_path = image_dir.join(format!("{}.qcow2", name)); + let (kernel, initrd) = action.extract(name).await?; let checksum_path = image_dir.join("checksums"); + let root_arg = detect_root_arg(&image_path) + .await + .unwrap_or_else(|_| default_root_arg()); let checksum = ShaSum { path: checksum_path, sha_type: ShaType::Sha512, @@ -409,383 +727,1146 @@ impl ImageMeta { path: image_path, kernel, initrd, + root_arg, 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?; + image.save(name).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?, - ), - }; + Ok(image) + } +} - 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(); +fn format_guestfs_failure(program: &str, output: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let details = match (stdout.is_empty(), stderr.is_empty()) { + (false, false) => format!( + "{} +{}", + stdout, stderr + ), + (false, true) => stdout, + (true, false) => stderr, + (true, true) => "(no output)".to_string(), + }; - let checksum_content = format!( - "{} {}\n{} {}\n{} {}\n", - image_hash, image_filename, kernel_hash, kernel_filename, initrd_hash, initrd_filename - ); + format!( + "{program} failed using the host libguestfs installation: +{details} +Qlean does not provision libguestfs appliances or other fallback paths at runtime. +Install/repair the host libguestfs-tools setup and verify it with: + LIBGUESTFS_BACKEND=direct libguestfs-test-tool" + ) +} + +async fn run_guestfs_tool( + program: &str, + args: &[&OsStr], + current_dir: &Path, +) -> Result { + let mut cmd = tokio::process::Command::new(program); + cmd.env("LIBGUESTFS_BACKEND", "direct") + .current_dir(current_dir); + for a in args { + cmd.arg(a); + } - tokio::fs::write(&image.checksum.path, checksum_content).await?; + let output = timeout(Duration::from_secs(180), cmd.output()) + .await + .with_context(|| format!("{program} timed out after 180s (libguestfs)"))? + .with_context(|| format!("failed to execute {program}"))?; - Ok(image) + if !output.status.success() { + bail!("{}", format_guestfs_failure(program, &output)); } + + Ok(output) } -// --------------------------------------------------------------------------- -// Debian -// --------------------------------------------------------------------------- +async fn guestfish_ls_boot(image_dir: &Path, file_name: &str) -> Result { + let args = [ + OsStr::new("--ro"), + OsStr::new("-a"), + OsStr::new(file_name), + OsStr::new("-i"), + OsStr::new("ls"), + OsStr::new("/boot"), + ]; + let output = run_guestfs_tool("guestfish", &args, image_dir).await?; + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} -#[derive(Debug, Default)] -pub struct Debian {} +async fn virt_copy_out(image_dir: &Path, file_name: &str, src: &str, _kind: &str) -> Result<()> { + let args = [ + OsStr::new("-a"), + OsStr::new(file_name), + OsStr::new(src), + OsStr::new("."), + ]; + let _output = run_guestfs_tool("virt-copy-out", &args, image_dir).await?; + Ok(()) +} -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))?; +async fn extract_boot_artifacts_guestfs( + image_dir: &Path, + file_name: &str, + distro: Distro, +) -> Result<(PathBuf, PathBuf)> { + ensure_extraction_prerequisites().await?; - 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 boot_dir = image_dir.join("boot"); + let _ = tokio::fs::remove_dir_all(&boot_dir).await; - let dirs = QleanDirs::new()?; - let image_path = dirs.images.join(name).join(&target_filename); + virt_copy_out(image_dir, file_name, "/boot", "boot directory").await?; - let download_url = format!( - "https://cloud.debian.org/images/cloud/trixie/latest/{}.qcow2", - name - ); + anyhow::ensure!( + boot_dir.exists(), + "virt-copy-out did not extract /boot into {}", + image_dir.display() + ); - // Single-pass download + hash computation - let computed_sha512 = - download_with_hash(&download_url, &image_path, ShaType::Sha512).await?; + let files = collect_files_recursive(&boot_dir).with_context(|| { + format!( + "failed to scan extracted boot tree in {}", + boot_dir.display() + ) + })?; - // 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 - ); + let kernel_src = choose_kernel_file(&files, distro.clone()) + .with_context(|| "failed to find kernel file in extracted /boot")?; + let initrd_src = choose_initrd_file(&files, distro) + .with_context(|| "failed to find initrd file in extracted /boot")?; - Ok(()) + let kernel_name = kernel_src + .file_name() + .and_then(|n| n.to_str()) + .with_context(|| "invalid kernel filename")? + .to_string(); + let initrd_name = initrd_src + .file_name() + .and_then(|n| n.to_str()) + .with_context(|| "invalid initrd filename")? + .to_string(); + + let kernel_path = image_dir.join(&kernel_name); + let initrd_path = image_dir.join(&initrd_name); + + fs::copy(&kernel_src, &kernel_path).with_context(|| { + format!( + "failed to copy extracted kernel {} -> {}", + kernel_src.display(), + kernel_path.display() + ) + })?; + fs::copy(&initrd_src, &initrd_path).with_context(|| { + format!( + "failed to copy extracted initrd {} -> {}", + initrd_src.display(), + initrd_path.display() + ) + })?; + + let kernel_args_path = kernel_args_hint_path(image_dir); + if let Some(args) = choose_kernel_options(&files, &kernel_name) { + fs::write(&kernel_args_path, args).with_context(|| { + format!( + "failed to write kernel args hint in {}", + image_dir.display() + ) + })?; + } else { + let _ = fs::remove_file(&kernel_args_path); } + let _ = fs::remove_file(root_hint_path(image_dir)); - 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 _ = tokio::fs::remove_dir_all(&boot_dir).await; + Ok((kernel_path, initrd_path)) +} - 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")?; +fn root_hint_path(image_dir: &Path) -> PathBuf { + image_dir.join(".root-partition") +} - if !output.status.success() { - bail!( - "guestfish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } +fn kernel_args_hint_path(image_dir: &Path) -> PathBuf { + image_dir.join(".kernel-args") +} - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; +fn read_u32_le(bytes: &[u8]) -> u32 { + let mut arr = [0u8; 4]; + arr.copy_from_slice(bytes); + u32::from_le_bytes(arr) +} - 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()); - } - } +fn read_u64_le(bytes: &[u8]) -> u64 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(bytes); + u64::from_le_bytes(arr) +} - 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))?; +#[derive(Debug, Clone)] +struct PartitionSlice { + number: usize, + start_lba: u64, + sectors: u64, +} - if !output.status.success() { - bail!( - "virt-copy-out failed for kernel: {}", - String::from_utf8_lossy(&output.stderr) - ); +fn parse_mbr_partitions(mbr: &[u8]) -> Vec { + let mut parts = Vec::new(); + for idx in 0..4usize { + let off = 446 + idx * 16; + let entry = &mbr[off..off + 16]; + let part_type = entry[4]; + if part_type == 0 { + continue; } + let start_lba = read_u32_le(&entry[8..12]) as u64; + let sectors = read_u32_le(&entry[12..16]) as u64; + if start_lba > 0 && sectors > 0 { + parts.push(PartitionSlice { + number: idx + 1, + start_lba, + sectors, + }); + } + } + parts +} - 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) - ); +fn parse_gpt_partitions(raw_path: &Path) -> Result> { + let mut f = fs::File::open(raw_path) + .with_context(|| format!("failed to open {}", raw_path.display()))?; + + let mut header = [0u8; 512]; + f.seek(SeekFrom::Start(512)) + .with_context(|| format!("failed to seek GPT header in {}", raw_path.display()))?; + f.read_exact(&mut header) + .with_context(|| format!("failed to read GPT header from {}", raw_path.display()))?; + + anyhow::ensure!( + &header[0..8] == b"EFI PART", + "{} does not contain a GPT header", + raw_path.display() + ); + + let entries_lba = read_u64_le(&header[72..80]); + let num_entries = read_u32_le(&header[80..84]) as usize; + let entry_size = read_u32_le(&header[84..88]) as usize; + anyhow::ensure!(entry_size >= 56, "invalid GPT entry size: {}", entry_size); + + let max_entries = num_entries.min(256); + let table_len = max_entries + .checked_mul(entry_size) + .with_context(|| "GPT partition table size overflow")?; + let mut table = vec![0u8; table_len]; + f.seek(SeekFrom::Start(entries_lba.saturating_mul(512))) + .with_context(|| format!("failed to seek GPT entries in {}", raw_path.display()))?; + f.read_exact(&mut table) + .with_context(|| format!("failed to read GPT entries from {}", raw_path.display()))?; + + let mut parts = Vec::new(); + for idx in 0..max_entries { + let off = idx * entry_size; + let entry = &table[off..off + entry_size]; + if entry[0..16].iter().all(|b| *b == 0) { + continue; } - let kernel_path = image_dir.join(&kernel_name); - let initrd_path = image_dir.join(&initrd_name); + let start_lba = read_u64_le(&entry[32..40]); + let end_lba = read_u64_le(&entry[40..48]); + if start_lba == 0 || end_lba < start_lba { + continue; + } - Ok((kernel_path, initrd_path)) + parts.push(PartitionSlice { + number: idx + 1, + start_lba, + sectors: end_lba - start_lba + 1, + }); } - fn distro(&self) -> Distro { - Distro::Debian - } + Ok(parts) } -// --------------------------------------------------------------------------- -// Ubuntu - uses pre-extracted kernel/initrd from official cloud images -// --------------------------------------------------------------------------- - -#[derive(Debug, Default)] -pub struct Ubuntu {} +fn list_partitions(raw_path: &Path) -> Result> { + let mut mbr = [0u8; 512]; + let mut f = fs::File::open(raw_path) + .with_context(|| format!("failed to open {}", raw_path.display()))?; + f.read_exact(&mut mbr) + .with_context(|| format!("failed to read MBR from {}", raw_path.display()))?; + + anyhow::ensure!( + mbr[510] == 0x55 && mbr[511] == 0xAA, + "{} does not look like a bootable disk image", + raw_path.display() + ); + + let protective_gpt = (0..4usize).any(|idx| mbr[446 + idx * 16 + 4] == 0xEE); + let mut parts = if protective_gpt { + parse_gpt_partitions(raw_path)? + } else { + parse_mbr_partitions(&mbr) + }; -impl ImageAction for Ubuntu { - async fn download(&self, name: &str) -> Result<()> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); + parts.sort_by(|a, b| { + b.sectors + .cmp(&a.sectors) + .then_with(|| a.number.cmp(&b.number)) + }); + anyhow::ensure!( + !parts.is_empty(), + "failed to find any partitions in {}", + raw_path.display() + ); + Ok(parts) +} - // Ubuntu noble (24.04 LTS) cloud image base URL - let base_url = "https://cloud-images.ubuntu.com/noble/current"; +fn collect_files_recursive(root: &Path) -> Result> { + let mut files = Vec::new(); + for entry in walkdir::WalkDir::new(root) { + let entry = entry.with_context(|| format!("failed while scanning {}", root.display()))?; + if entry.file_type().is_file() { + files.push(entry.path().to_path_buf()); + } + } + Ok(files) +} - // 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?; +fn choose_kernel_file(files: &[PathBuf], distro: Distro) -> Option { + let mut candidates = files + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| match distro { + Distro::Fedora => n.starts_with("vmlinuz") && !n.contains("rescue"), + Distro::Arch => n.starts_with("vmlinuz"), + _ => n.starts_with("vmlinuz"), + }) + .unwrap_or(false) + }) + .cloned() + .collect::>(); + candidates.sort(); + candidates.into_iter().last() +} - // 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?; +fn choose_initrd_file(files: &[PathBuf], distro: Distro) -> Option { + let mut candidates = files + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| match distro { + Distro::Ubuntu | Distro::Debian => n.starts_with("initrd.img"), + Distro::Fedora => { + n.starts_with("initramfs") && n.ends_with(".img") && !n.contains("rescue") + } + Distro::Arch => { + n.starts_with("initramfs") + && n.ends_with(".img") + && n.contains("linux") + && !n.contains("fallback") + } + Distro::Custom => false, + }) + .unwrap_or(false) + }) + .cloned() + .collect::>(); + + if candidates.is_empty() && matches!(distro, Distro::Arch) { + candidates = files + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| { + n.starts_with("initramfs") && n.ends_with(".img") && n.contains("linux") + }) + .unwrap_or(false) + }) + .cloned() + .collect::>(); + } - // 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?; + candidates.sort(); + candidates.into_iter().last() +} - Ok(()) +fn normalize_kernel_options(raw: &str) -> Option { + let tokens = raw + .split_whitespace() + .filter(|token| !token.is_empty() && *token != "ro" && *token != "rw") + .collect::>(); + if tokens.is_empty() { + None + } else { + Some(tokens.join(" ")) } +} - 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); +fn resolve_kernelopts_from_grub_cfg(content: &str) -> Option { + // Fedora/GRUB often stores a full kernel command line in a variable called "kernelopts", + // referenced from BLS entries as: `options $kernelopts`. + // + // We intentionally keep this parser lightweight and dependency-free. + for line in content.lines() { + let trimmed = line.trim(); + // Common forms: + // set kernelopts="root=UUID=... ro ..." + // set kernelopts='root=UUID=... ro ...' + if let Some(rest) = trimmed.strip_prefix("set kernelopts=") { + let rest = rest.trim(); + if let Some(stripped) = rest.strip_prefix('"') + && let Some(end) = stripped.find('"') + { + return Some(stripped[..end].to_string()); + } + if let Some(stripped) = rest.strip_prefix('\'') + && let Some(end) = stripped.find('\'') + { + return Some(stripped[..end].to_string()); + } + // Fallback: no quotes, take the remainder of the token. + let first = rest.split_whitespace().next().unwrap_or(""); + if !first.is_empty() { + return Some(first.to_string()); + } + } - let kernel = image_dir.join("vmlinuz"); - let initrd = image_dir.join("initrd.img"); + // Some grub.cfg variants assign without the "set" keyword. + if let Some(idx) = trimmed.find("kernelopts=") { + let rest = trimmed[idx + "kernelopts=".len()..].trim(); + if let Some(stripped) = rest.strip_prefix('"') + && let Some(end) = stripped.find('"') + { + return Some(stripped[..end].to_string()); + } + if let Some(stripped) = rest.strip_prefix('\'') + && let Some(end) = stripped.find('\'') + { + return Some(stripped[..end].to_string()); + } + } + } + None +} - anyhow::ensure!(kernel.exists(), "kernel file not found after download"); - anyhow::ensure!(initrd.exists(), "initrd file not found after download"); +fn resolve_kernelopts_from_grubenv(bytes: &[u8]) -> Option { + // grubenv is a binary environment block, but it commonly contains plain ASCII entries + // like: `kernelopts=root=UUID=... ro ...`. + let needle = b"kernelopts="; + let pos = bytes.windows(needle.len()).position(|w| w == needle)?; + let start = pos + needle.len(); + let mut end = start; + while end < bytes.len() { + let b = bytes[end]; + if b == b'\n' || b == 0 { + break; + } + end += 1; + } + let s = String::from_utf8_lossy(&bytes[start..end]) + .trim() + .to_string(); + if s.is_empty() { None } else { Some(s) } +} - Ok((kernel, initrd)) +fn resolve_kernelopts(files: &[PathBuf]) -> Option { + // Prefer grub.cfg (human-readable). + for path in files.iter() { + if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == "grub.cfg") + .unwrap_or(false) + && let Ok(content) = fs::read_to_string(path) + && let Some(v) = resolve_kernelopts_from_grub_cfg(&content) + { + return Some(v); + } } - fn distro(&self) -> Distro { - Distro::Ubuntu + // Fallback: scan grubenv blocks. + for path in files.iter() { + if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == "grubenv") + .unwrap_or(false) + && let Ok(bytes) = fs::read(path) + && let Some(v) = resolve_kernelopts_from_grubenv(&bytes) + { + return Some(v); + } } -} -// --------------------------------------------------------------------------- -// Fedora - uses pre-extracted kernel/initrd from official cloud images -// --------------------------------------------------------------------------- + None +} -#[derive(Debug, Default)] -pub struct Fedora {} +fn expand_kernelopts(options: &str, kernelopts: Option<&str>) -> Option { + let ko = kernelopts?; + if options.contains("$kernelopts") || options.contains("${kernelopts}") { + let expanded = options + .replace("${kernelopts}", ko) + .replace("$kernelopts", ko); + return normalize_kernel_options(&expanded); + } + None +} -impl ImageAction for Fedora { - async fn download(&self, name: &str) -> Result<()> { - let dirs = QleanDirs::new()?; - let image_dir = dirs.images.join(name); +fn extract_loader_entry_options(entry: &str, kernel_name: &str) -> Option { + let mut linux_matches = false; + let mut options = None; - // Fedora 41 Cloud Base image - let base_url = - "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images"; + for line in entry.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } - // Image filename - let image_filename = "Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2"; + if let Some(rest) = trimmed.strip_prefix("linux ") { + let linux_path = rest.split_whitespace().next().unwrap_or_default(); + let linux_base = std::path::Path::new(linux_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(linux_path); + // Some images use a symlink like "vmlinuz" in loader entries while the extracted + // kernel file is versioned (or vice-versa). Prefer an exact match, but allow a + // conservative compatibility match when either side is the common "vmlinuz" alias. + linux_matches = linux_base == kernel_name + || (linux_base == "vmlinuz" && kernel_name.starts_with("vmlinuz")) + || (kernel_name == "vmlinuz" && linux_base.starts_with("vmlinuz")); + } else if let Some(rest) = trimmed.strip_prefix("options ") { + options = normalize_kernel_options(rest); + } + } - // 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?; + if linux_matches { options } else { None } +} - // Fedora cloud images don't provide pre-extracted boot files - // We'll need to extract them using guestfish - Ok(()) +fn extract_grub_linux_options(line: &str, kernel_name: &str) -> Option { + let trimmed = line.trim(); + let prefixes = ["linux ", "linuxefi ", "linux16 "]; + let rest = prefixes + .iter() + .find_map(|prefix| trimmed.strip_prefix(prefix))?; + + let mut parts = rest.split_whitespace(); + let kernel_path = parts.next()?; + let kernel_base = std::path::Path::new(kernel_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(kernel_path); + if kernel_base != kernel_name { + return None; } - 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); + normalize_kernel_options(&parts.collect::>().join(" ")) +} - // 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")?; +fn choose_kernel_options(files: &[PathBuf], kernel_name: &str) -> Option { + let kernelopts = resolve_kernelopts(files); + + let mut loader_entries = files + .iter() + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("conf")) + .cloned() + .collect::>(); + loader_entries.sort(); + loader_entries.reverse(); + + // First pass: find an entry whose "linux" line matches the extracted kernel. + for path in loader_entries.iter() { + let content = match fs::read_to_string(path) { + Ok(v) => v, + Err(_) => continue, + }; + if let Some(options) = extract_loader_entry_options(&content, kernel_name) { + if let Some(expanded) = expand_kernelopts(&options, kernelopts.as_deref()) { + return Some(expanded); + } + return Some(options); + } + } - if !output.status.success() { - bail!( - "guestfish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + // Second pass: if the loader entry does not reference the exact same kernel filename + // (eg. symlink vs versioned kernel), fall back to the newest *non-rescue* entry that + // contains a root= argument. + for path in loader_entries.iter() { + let content = match fs::read_to_string(path) { + Ok(v) => v, + Err(_) => continue, + }; + let lower = content.to_lowercase(); + if lower.contains("rescue") || lower.contains("recovery") || lower.contains("fallback") { + continue; } + let mut options_line = None; + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("options ") { + options_line = normalize_kernel_options(rest); + break; + } + } + if let Some(opts) = options_line { + if let Some(expanded) = expand_kernelopts(&opts, kernelopts.as_deref()) + && expanded.split_whitespace().any(|t| t.starts_with("root=")) + { + return Some(expanded); + } + if opts.split_whitespace().any(|t| t.starts_with("root=")) { + return Some(opts); + } + } + } - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; + let mut grub_cfgs = files + .iter() + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("cfg")) + .cloned() + .collect::>(); + grub_cfgs.sort(); + grub_cfgs.reverse(); + + // First pass: grub.cfg linux line matches the extracted kernel. + for path in grub_cfgs.iter() { + let content = match fs::read_to_string(path) { + Ok(v) => v, + Err(_) => continue, + }; + for line in content.lines() { + if let Some(options) = extract_grub_linux_options(line, kernel_name) { + if let Some(expanded) = expand_kernelopts(&options, kernelopts.as_deref()) { + return Some(expanded); + } + return Some(options); + } + } + } - 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()); + // Second pass: fall back to the first non-rescue linux line that contains a root= argument. + for path in grub_cfgs.iter() { + let content = match fs::read_to_string(path) { + Ok(v) => v, + Err(_) => continue, + }; + for line in content.lines() { + let trimmed = line.trim(); + let prefixes = ["linux ", "linuxefi ", "linux16 "]; + let rest = match prefixes.iter().find_map(|p| trimmed.strip_prefix(p)) { + Some(v) => v, + None => continue, + }; + if trimmed.contains("rescue") || trimmed.contains("recovery") { + continue; + } + let parts = rest.split_whitespace().collect::>(); + if parts.len() < 2 { + continue; + } + if let Some(opts) = normalize_kernel_options(&parts[1..].join(" ")) { + if let Some(expanded) = expand_kernelopts(&opts, kernelopts.as_deref()) + && expanded.split_whitespace().any(|t| t.starts_with("root=")) + { + return Some(expanded); + } + if opts.split_whitespace().any(|t| t.starts_with("root=")) { + return Some(opts); + } } } + } + + None +} + +fn partition_size_bytes(part: &PartitionSlice) -> u64 { + part.sectors.saturating_mul(512) +} + +async fn has_command(cmd: &str, arg: &str) -> bool { + tokio::process::Command::new(cmd) + .arg(arg) + .output() + .await + .is_ok() +} - 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) +#[allow(dead_code)] +async fn check_userspace_extract_tools() -> Result<()> { + for (cmd, arg) in [("qemu-img", "--version"), ("dd", "--version")] { + tokio::process::Command::new(cmd) + .arg(arg) .output() .await - .with_context(|| format!("failed to execute virt-copy-out for {}", kernel_name))?; + .with_context(|| format!("could not find {}", cmd))?; + } - if !output.status.success() { - bail!( - "virt-copy-out failed for kernel: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + let have_debugfs = has_command("debugfs", "-V").await; + let have_mcopy = has_command("mcopy", "-V").await; + let have_7z = has_command("7z", "i").await; + anyhow::ensure!( + have_debugfs || have_mcopy || have_7z, + "userspace extraction requires debugfs, mcopy, or 7z" + ); + Ok(()) +} + +async fn write_partition_image( + raw_path: &Path, + image_dir: &Path, + part: &PartitionSlice, +) -> Result { + let part_path = image_dir.join(format!(".extract-part-{}.img", part.number)); + let _ = tokio::fs::remove_file(&part_path).await; + + let output = tokio::process::Command::new("dd") + .arg(format!("if={}", raw_path.display())) + .arg(format!("of={}", part_path.display())) + .arg("bs=512") + .arg(format!("skip={}", part.start_lba)) + .arg(format!("count={}", part.sectors)) + .arg("status=none") + .output() + .await + .with_context(|| format!("failed to execute dd for partition {}", part.number))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("dd failed for partition {}: {}", part.number, stderr.trim()); + } + + Ok(part_path) +} + +async fn dump_partition_subdir_with_mcopy( + part_path: &Path, + dump_dir: &Path, + subdir: &str, +) -> Result> { + if !has_command("mcopy", "-V").await { + return Ok(None); + } + + let source = match subdir { + "/" => "::/", + "/boot" => "::/boot", + _ => return Ok(None), + }; + + let output = tokio::process::Command::new("mcopy") + .arg("-s") + .arg("-n") + .arg("-i") + .arg(part_path) + .arg(source) + .arg(dump_dir) + .output() + .await + .with_context(|| format!("failed to execute mcopy against {}", part_path.display()))?; + + if !output.status.success() { + let _ = tokio::fs::remove_dir_all(dump_dir).await; + return Ok(None); + } + + Ok(Some(dump_dir.to_path_buf())) +} + +async fn dump_partition_subdir_with_7z( + part_path: &Path, + dump_dir: &Path, +) -> Result> { + if !has_command("7z", "i").await { + return Ok(None); + } + + let output = tokio::process::Command::new("7z") + .arg("x") + .arg("-y") + .arg(format!("-o{}", dump_dir.display())) + .arg(part_path) + .output() + .await + .with_context(|| format!("failed to execute 7z against {}", part_path.display()))?; + + if !output.status.success() { + let _ = tokio::fs::remove_dir_all(dump_dir).await; + return Ok(None); + } + + Ok(Some(dump_dir.to_path_buf())) +} + +async fn dump_partition_subdir( + part_path: &Path, + image_dir: &Path, + part: &PartitionSlice, + subdir: &str, +) -> Result> { + let sanitized = if subdir == "/" { "root" } else { "boot" }; + let dump_dir = image_dir.join(format!(".extract-{}-{}", sanitized, part.number)); + let _ = tokio::fs::remove_dir_all(&dump_dir).await; + + tokio::fs::create_dir_all(&dump_dir) + .await + .with_context(|| format!("failed to create {}", dump_dir.display()))?; - // 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) + if has_command("debugfs", "-V").await { + let output = tokio::process::Command::new("debugfs") + .arg("-R") + .arg(format!("rdump {} {}", subdir, dump_dir.display())) + .arg(part_path) .output() .await - .with_context(|| format!("failed to execute virt-copy-out for {}", initrd_name))?; + .with_context(|| format!("failed to execute debugfs for partition {}", part.number))?; - if !output.status.success() { - bail!( - "virt-copy-out failed for initrd: {}", - String::from_utf8_lossy(&output.stderr) - ); + if output.status.success() { + return Ok(Some(dump_dir)); + } + } + + if let Some(dir) = dump_partition_subdir_with_mcopy(part_path, &dump_dir, subdir).await? { + return Ok(Some(dir)); + } + + if let Some(dir) = dump_partition_subdir_with_7z(part_path, &dump_dir).await? { + return Ok(Some(dir)); + } + + let _ = tokio::fs::remove_dir_all(&dump_dir).await; + Ok(None) +} + +async fn partition_contains_os_release( + part_path: &Path, + image_dir: &Path, + part: &PartitionSlice, +) -> Result { + let probe_path = image_dir.join(format!(".extract-os-release-{}", part.number)); + let _ = tokio::fs::remove_file(&probe_path).await; + + let output = tokio::process::Command::new("debugfs") + .arg("-R") + .arg(format!("dump -p /etc/os-release {}", probe_path.display())) + .arg(part_path) + .output() + .await + .with_context(|| { + format!( + "failed to probe /etc/os-release in partition {}", + part.number + ) + })?; + + let exists = output.status.success() && probe_path.exists(); + let _ = tokio::fs::remove_file(&probe_path).await; + Ok(exists) +} + +#[allow(dead_code)] +async fn extract_boot_artifacts_userspace( + image_dir: &Path, + file_name: &str, + distro: Distro, +) -> Result<(PathBuf, PathBuf)> { + check_userspace_extract_tools().await?; + + let raw_path = image_dir.join(format!("{}.raw", file_name)); + let _ = tokio::fs::remove_file(&raw_path).await; + + let convert = tokio::process::Command::new("qemu-img") + .arg("convert") + .arg("-O") + .arg("raw") + .arg(file_name) + .arg(&raw_path) + .current_dir(image_dir) + .output() + .await + .with_context(|| format!("failed to convert {} to raw", file_name))?; + if !convert.status.success() { + let stderr = String::from_utf8_lossy(&convert.stderr); + bail!("qemu-img convert failed: {}", stderr.trim()); + } + + let parts = list_partitions(&raw_path)?; + let mut root_hint = None; + let mut last_err = None; + + for part in &parts { + let part_path = match write_partition_image(&raw_path, image_dir, part).await { + Ok(path) => path, + Err(err) => { + last_err = Some(err); + continue; + } + }; + + match partition_contains_os_release(&part_path, image_dir, part).await { + Ok(true) => { + root_hint = Some(part.number); + } + Ok(false) => {} + Err(err) => { + last_err = Some(err); + } + } + + let _ = tokio::fs::remove_file(&part_path).await; + if root_hint.is_some() { + break; + } + } + + for part in &parts { + let part_path = match write_partition_image(&raw_path, image_dir, part).await { + Ok(path) => path, + Err(err) => { + last_err = Some(err); + continue; + } + }; + + let mut dump_candidates = vec![("/boot", false)]; + if partition_size_bytes(part) <= 2 * 1024 * 1024 * 1024 { + dump_candidates.push(("/", true)); } + let mut found = None; + for (subdir, is_partition_root) in dump_candidates { + let dump_dir = match dump_partition_subdir(&part_path, image_dir, part, subdir).await { + Ok(Some(dir)) => dir, + Ok(None) => continue, + Err(err) => { + last_err = Some(err); + continue; + } + }; + + let files = match collect_files_recursive(&dump_dir) { + Ok(v) => v, + Err(err) => { + let _ = tokio::fs::remove_dir_all(&dump_dir).await; + last_err = Some(err); + continue; + } + }; + + let kernel_src = choose_kernel_file(&files, distro.clone()); + let initrd_src = choose_initrd_file(&files, distro.clone()); + if let (Some(kernel_src), Some(initrd_src)) = (kernel_src, initrd_src) { + let kernel_name = kernel_src + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let kernel_args = choose_kernel_options(&files, &kernel_name); + found = Some(( + dump_dir, + kernel_src, + initrd_src, + is_partition_root, + kernel_args, + )); + break; + } + + let _ = tokio::fs::remove_dir_all(&dump_dir).await; + } + + let Some((dump_dir, kernel_src, initrd_src, is_partition_root, kernel_args)) = found else { + let _ = tokio::fs::remove_file(&part_path).await; + continue; + }; + + let kernel_name = kernel_src + .file_name() + .and_then(|n| n.to_str()) + .with_context(|| "invalid kernel filename")? + .to_string(); + let initrd_name = initrd_src + .file_name() + .and_then(|n| n.to_str()) + .with_context(|| "invalid initrd filename")? + .to_string(); + let kernel_path = image_dir.join(&kernel_name); let initrd_path = image_dir.join(&initrd_name); - Ok((kernel_path, initrd_path)) + fs::copy(&kernel_src, &kernel_path).with_context(|| { + format!( + "failed to copy extracted kernel {} -> {}", + kernel_src.display(), + kernel_path.display() + ) + })?; + fs::copy(&initrd_src, &initrd_path).with_context(|| { + format!( + "failed to copy extracted initrd {} -> {}", + initrd_src.display(), + initrd_path.display() + ) + })?; + + let chosen_root = if is_partition_root { + root_hint + .or_else(|| { + parts + .iter() + .find(|candidate| candidate.number != part.number) + .map(|candidate| candidate.number) + }) + .unwrap_or(part.number) + } else { + root_hint.unwrap_or(part.number) + }; + fs::write(root_hint_path(image_dir), chosen_root.to_string()).with_context(|| { + format!( + "failed to write root partition hint in {}", + image_dir.display() + ) + })?; + if let Some(args) = kernel_args { + fs::write(kernel_args_hint_path(image_dir), args).with_context(|| { + format!( + "failed to write kernel args hint in {}", + image_dir.display() + ) + })?; + } + + let _ = tokio::fs::remove_dir_all(&dump_dir).await; + let _ = tokio::fs::remove_file(&part_path).await; + let _ = tokio::fs::remove_file(&raw_path).await; + return Ok((kernel_path, initrd_path)); } - fn distro(&self) -> Distro { - Distro::Fedora + let _ = tokio::fs::remove_file(&raw_path).await; + + if let Some(err) = last_err { + return Err(err) + .with_context(|| "userspace qcow2 extraction did not find a usable /boot tree"); } + + bail!("userspace qcow2 extraction did not find kernel/initrd in any partition"); +} + +async fn detect_root_arg(image_path: &Path) -> Result { + let image_dir = image_path.parent().with_context(|| "missing image dir")?; + let kernel_args_path = kernel_args_hint_path(image_dir); + if let Ok(args) = fs::read_to_string(&kernel_args_path) { + let trimmed = args.trim(); + if !trimmed.is_empty() { + // Some guestfs outputs include a trailing ':' after device names (e.g. /dev/sda3:). + // Also, some bootloader entries may embed `root=/dev/vda3:`. + // Sanitize these so direct-kernel boot does not hang waiting for a non-existent device. + let sanitized = trimmed + .split_whitespace() + .map(|t| { + if let Some(rest) = t.strip_prefix("root=") { + let clean = rest.trim_end_matches(':'); + format!("root={clean}") + } else { + t.to_string() + } + }) + .collect::>() + .join(" "); + return Ok(sanitized); + } + } + + let hint_path = root_hint_path(image_dir); + if let Ok(hint) = fs::read_to_string(&hint_path) + && let Ok(part) = hint.trim().parse::() + { + return Ok(format!("root=/dev/vda{}", part)); + } + + let file_name = image_path + .file_name() + .and_then(|v| v.to_str()) + .with_context(|| "invalid image filename")?; + let args = [ + OsStr::new("--ro"), + OsStr::new("-a"), + OsStr::new(file_name), + OsStr::new("-i"), + OsStr::new("mountpoints"), + ]; + let output = run_guestfs_tool("guestfish", &args, image_dir).await?; + if !output.status.success() { + return Ok(default_root_arg()); + } + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let mut dev = None; + if parts[0].starts_with("/dev/") && parts[1] == "/" { + dev = Some(parts[0]); + } + if parts[1].starts_with("/dev/") && parts[0] == "/" { + dev = Some(parts[1]); + } + if let Some(d) = dev { + // guestfish `mountpoints` typically prints `/dev/sda3: /`. + let d = d.trim_end_matches(':'); + let virt = if let Some(rest) = d.strip_prefix("/dev/sd") { + format!("/dev/vd{}", rest) + } else { + d.to_string() + }; + return Ok(format!("root={}", virt)); + } + } + Ok(default_root_arg()) } // --------------------------------------------------------------------------- -// Arch - uses official cloud images +// Debian // --------------------------------------------------------------------------- #[derive(Debug, Default)] -pub struct Arch {} +pub struct Debian {} -impl ImageAction for Arch { +impl ImageAction for Debian { async fn download(&self, name: &str) -> Result<()> { + download_remote_image(name, Distro::Debian).await + } + + 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); - // 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"; + extract_boot_artifacts_guestfs(&image_dir, &file_name, Distro::Debian) + .await + .with_context(|| "failed to extract Debian kernel/initrd from 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?; + fn distro(&self) -> Distro { + Distro::Debian + } +} - Ok(()) +// --------------------------------------------------------------------------- +// Ubuntu - downloads the official cloud image and extracts kernel/initrd via libguestfs +// --------------------------------------------------------------------------- + +#[derive(Debug, Default)] +pub struct Ubuntu {} + +impl ImageAction for Ubuntu { + async fn download(&self, name: &str) -> Result<()> { + download_remote_image(name, Distro::Ubuntu).await } async fn extract(&self, name: &str) -> Result<(PathBuf, PathBuf)> { @@ -793,87 +1874,63 @@ impl ImageAction for Arch { 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() + extract_boot_artifacts_guestfs(&image_dir, &file_name, Distro::Ubuntu) .await - .with_context(|| "failed to execute guestfish")?; + .with_context(|| "failed to extract Ubuntu kernel/initrd from qcow2") + } - if !output.status.success() { - bail!( - "guestfish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + fn distro(&self) -> Distro { + Distro::Ubuntu + } +} - let boot_files = String::from_utf8_lossy(&output.stdout); - let mut kernel_name = None; - let mut initrd_name = None; +// --------------------------------------------------------------------------- +// Fedora - uses the official cloud image and extracts kernel/initrd via libguestfs +// --------------------------------------------------------------------------- - 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()); - } - } +#[derive(Debug, Default)] +pub struct Fedora {} - 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))?; +impl ImageAction for Fedora { + async fn download(&self, name: &str) -> Result<()> { + download_remote_image(name, Distro::Fedora).await + } - if !output.status.success() { - bail!( - "virt-copy-out failed for kernel: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + 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); - // 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) - .output() + extract_boot_artifacts_guestfs(&image_dir, &file_name, Distro::Fedora) .await - .with_context(|| format!("failed to execute virt-copy-out for {}", initrd_name))?; + .with_context(|| "failed to extract Fedora kernel/initrd from qcow2") + } - if !output.status.success() { - bail!( - "virt-copy-out failed for initrd: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + fn distro(&self) -> Distro { + Distro::Fedora + } +} - let kernel_path = image_dir.join(&kernel_name); - let initrd_path = image_dir.join(&initrd_name); +// --------------------------------------------------------------------------- +// Arch - uses the official cloud image and extracts kernel/initrd via libguestfs +// --------------------------------------------------------------------------- + +#[derive(Debug, Default)] +pub struct Arch {} + +impl ImageAction for Arch { + async fn download(&self, name: &str) -> Result<()> { + download_remote_image(name, Distro::Arch).await + } + + 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); - Ok((kernel_path, initrd_path)) + extract_boot_artifacts_guestfs(&image_dir, &file_name, Distro::Arch) + .await + .with_context(|| "failed to extract Arch kernel/initrd from qcow2") } fn distro(&self) -> Distro { @@ -882,7 +1939,7 @@ impl ImageAction for Arch { } // --------------------------------------------------------------------------- -// Custom - user-provided image with flexible configuration (WSL-friendly) +// Custom - user-provided image with flexible configuration // --------------------------------------------------------------------------- #[derive(Debug)] @@ -903,7 +1960,7 @@ impl ImageAction for Custom { // Download main image file let image_path = image_dir.join(format!("{}.qcow2", name)); - download_or_copy_with_hash( + materialize_source_with_hash( &self.config.image_source, &image_path, &self.config.image_hash, @@ -916,7 +1973,7 @@ impl ImageAction for Custom { (&self.config.kernel_source, &self.config.kernel_hash) { let kernel_path = image_dir.join("vmlinuz"); - download_or_copy_with_hash( + materialize_source_with_hash( kernel_src, &kernel_path, kernel_hash, @@ -930,7 +1987,7 @@ impl ImageAction for Custom { (&self.config.initrd_source, &self.config.initrd_hash) { let initrd_path = image_dir.join("initrd.img"); - download_or_copy_with_hash( + materialize_source_with_hash( initrd_src, &initrd_path, initrd_hash, @@ -946,86 +2003,41 @@ impl ImageAction for Custom { 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 + ensure_extraction_prerequisites().await?; let file_name = format!("{}.qcow2", name); + let boot_files = guestfish_ls_boot(&image_dir, &file_name).await?; - 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()); - } + let mut kernel_name = None; + let mut initrd_name = None; + 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 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))); + if initrd_name.is_none() + && (file.starts_with("initrd") || file.starts_with("initramfs")) + { + initrd_name = Some(file.to_string()); } } - // 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" - ); + let kernel = kernel_name.with_context(|| "failed to find kernel file in /boot")?; + let initrd = initrd_name.with_context(|| "failed to find initrd file in /boot")?; + + let kernel_src = format!("/boot/{}", kernel); + virt_copy_out(&image_dir, &file_name, &kernel_src, "kernel").await?; + let initrd_src = format!("/boot/{}", initrd); + virt_copy_out(&image_dir, &file_name, &initrd_src, "initrd").await?; + + Ok((image_dir.join(&kernel), image_dir.join(&initrd))) } fn distro(&self) -> Distro { @@ -1034,27 +2046,6 @@ impl ImageAction for 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 // --------------------------------------------------------------------------- @@ -1113,6 +2104,20 @@ impl Image { Image::Custom(img) => &img.initrd, } } + + pub fn root_arg(&self) -> &str { + match self { + Image::Debian(img) => &img.root_arg, + Image::Ubuntu(img) => &img.root_arg, + Image::Fedora(img) => &img.root_arg, + Image::Arch(img) => &img.root_arg, + Image::Custom(img) => &img.root_arg, + } + } + + pub fn prefer_direct_kernel_boot(&self) -> bool { + true + } } /// Factory function to create Image instances based on distro @@ -1202,31 +2207,35 @@ mod tests { use super::*; use serial_test::serial; + fn test_subscriber_init() { + // Keep test logging setup local to this module to avoid coupling + // image unit tests to integration-test helpers. + use std::sync::Once; + use tracing_subscriber::{EnvFilter, fmt::time::LocalTime}; + + static INIT: Once = Once::new(); + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_timer(LocalTime::rfc_3339()) + .try_init() + .ok(); + }); + } + #[test] - fn test_find_sha512_for_exact_filename() { + fn test_find_hash_for_exact_filename() { 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"); + let result = find_hash_for_file(checksums, "debian-13-generic-amd64.qcow2"); assert_eq!( result, Some("f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea67709154d84220059672758508afbb0691c41ba8aa6d76818d89d65".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 { @@ -1242,8 +2251,68 @@ f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea6770915 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("kernel123".to_string())); + assert_eq!(decoded, config); + } + + #[test] + fn test_image_sources_config_toml_parse() { + let config: ImageSourcesConfig = toml::from_str( + r#" +[debian] +image_url = "https://example.com/debian.qcow2" +checksum_url = "https://example.com/SHA512SUMS" +checksum_entry = "debian.qcow2" +checksum_type = "Sha512" + +[ubuntu] +image_url = "https://example.com/ubuntu.img" +checksum_url = "https://example.com/SHA256SUMS" +checksum_entry = "ubuntu.img" +checksum_type = "Sha256" + +[fedora] +image_url = "https://example.com/fedora.qcow2" +checksum_url = "https://example.com/CHECKSUM" +checksum_entry = "fedora.qcow2" +checksum_type = "Sha256" + +[arch] +image_url = "https://example.com/arch.qcow2" +checksum_url = "https://example.com/arch.SHA256" +checksum_entry = "arch.qcow2" +checksum_type = "Sha256" +"#, + ) + .unwrap(); + + assert_eq!(config.debian.checksum_type, ShaType::Sha512); + assert_eq!(config.ubuntu.checksum_entry, "ubuntu.img"); + assert_eq!(config.fedora.image_url, "https://example.com/fedora.qcow2"); + assert_eq!(config.arch.checksum_url, "https://example.com/arch.SHA256"); + } + + #[test] + 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()) + ); + + // 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()) + ); + + // 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] @@ -1301,4 +2370,57 @@ f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea6770915 Ok(()) } + + #[tokio::test] + #[serial] + async fn test_custom_image_nonexistent_local_path() -> Result<()> { + test_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<()> { + test_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/src/machine.rs b/src/machine.rs index 0e082ad..bb7c694 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -54,6 +54,8 @@ pub struct MachineImage { pub kernel: PathBuf, pub initrd: PathBuf, pub seed: PathBuf, + pub root_arg: String, + pub prefer_direct_kernel_boot: bool, } #[derive(Clone, Debug)] @@ -80,6 +82,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 +179,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 (no TCP fallback): 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() @@ -179,6 +355,8 @@ impl Machine { kernel: image.kernel().to_owned(), initrd: image.initrd().to_owned(), seed: seed_iso_path, + root_arg: image.root_arg().to_string(), + prefer_direct_kernel_boot: image.prefer_direct_kernel_boot(), }; Ok(Self { @@ -272,15 +450,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 +785,59 @@ impl Machine { self.cid, self.keypair.privkey_path, ); + let kvm_available = KVM_AVAILABLE.get().copied().unwrap_or(false); + // SSH reachability can be slow on first boot (cloud-init + sshd startup), especially on + // slower disks or under nested virtualization. + let ssh_timeout = if 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 +845,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..bfe3436 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -17,7 +17,9 @@ use tracing::{debug, error, info, trace, warn}; use crate::{ KVM_AVAILABLE, MachineConfig, machine::MachineImage, - utils::{CommandExt, QleanDirs}, + utils::{ + CommandExt, QLEAN_BRIDGE_NAME, QleanDirs, bridge_conf_allows, has_iface, has_vsock_support, + }, }; const QEMU_TIMEOUT: Duration = Duration::from_secs(360 * 60); // 6 hours @@ -36,22 +38,65 @@ 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"); + 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(["-machine", "hpet=off"]); + + // Qlean's SSH transport is vhost-vsock. Without it we cannot reach the guest. + anyhow::ensure!( + has_vsock_support(), + "Missing /dev/vhost-vsock; vhost-vsock is required (no TCP fallback)." + ); + qemu_cmd.args([ + "-device", + &format!( + "vhost-vsock-pci,id=vhost-vsock-pci0,guest-cid={}", + params.cid + ), + ]); + + let use_direct_kernel_boot = params.image.prefer_direct_kernel_boot + && params.image.kernel.exists() + && params.image.initrd.exists() + && std::fs::metadata(¶ms.image.kernel) + .map(|m| m.len() > 0) + .unwrap_or(false) + && std::fs::metadata(¶ms.image.initrd) + .map(|m| m.len() > 0) + .unwrap_or(false); + + anyhow::ensure!( + use_direct_kernel_boot, + "Kernel/initrd extraction is required before QEMU launch." + ); + + // Qlean configures an SSH endpoint over vsock through cloud-init. The guest listens on + // vsock port 22 and proxies to its regular TCP sshd listener. + // + // For Fedora/Arch images, a minimal "root=/dev/vdaX" command line is often insufficient. + // Cloud images may rely on BLS/GRUB kernelopts (UUID/rootflags/btrfs subvols, etc.). + // We therefore pass through the full kernel args extracted from /boot when available. + let mut tokens = params.image.root_arg.split_whitespace().collect::>(); + if !tokens.iter().any(|t| *t == "rw" || *t == "ro") { + tokens.push("rw"); + } + // Force NoCloud datasource for cloud-init so guests don't spend time probing + // metadata services that are unavailable in this test environment. + if !tokens.iter().any(|t| t.starts_with("ds=")) { + tokens.push("ds=nocloud"); + } + if !tokens.iter().any(|t| t.starts_with("console=")) { + tokens.push("console=ttyS0,115200n8"); + } + let kernel_cmdline = tokens.join(" "); + + qemu_cmd .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()]) + .args(["-append", &kernel_cmdline]) + .args(["-initrd", params.image.initrd.to_str().unwrap()]); + + qemu_cmd // Disk .args([ "-drive", @@ -61,25 +106,59 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { ), ]) // No GUI - .arg("-nographic") - // Network - .args(["-netdev", "bridge,id=net0,br=qlbr0"]) + .arg("-nographic"); + + // --------------------------------------------------------------------- + // Network + // Qlean requires the libvirt-managed bridge. + // --------------------------------------------------------------------- + let want_bridge = has_iface(QLEAN_BRIDGE_NAME) && bridge_conf_allows(QLEAN_BRIDGE_NAME); + + anyhow::ensure!( + want_bridge, + "QEMU bridge helper is not configured to allow '{}'. Hint: add `allow {}` (or `allow all`) to /etc/qemu/bridge.conf.", + QLEAN_BRIDGE_NAME, + QLEAN_BRIDGE_NAME + ); + + 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() ), ]); @@ -96,7 +175,8 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { } // Spawn QEMU process - debug!("Spawning QEMU with command:\n{:?}", qemu_cmd.to_string()); + info!("Starting QEMU"); + debug!("QEMU command: {:?}", qemu_cmd.to_string()); let mut qemu_child = qemu_cmd .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -106,8 +186,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 +195,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 +205,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..b372db2 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,18 @@ 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, error, info, warn}; + +// MAC address string in canonical form (e.g. "52:54:00:aa:bb:cc"). + +// Avoid adding a libc dependency just to match a couple errno constants. +// These values are stable across Linux and are used only for user-facing hints. +const ERR_EACCES: i32 = 13; // Permission denied +const ERR_ENODEV: i32 = 19; // No such device #[derive(Clone, Debug)] pub struct PersistedSshKeypair { @@ -83,7 +90,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 +119,7 @@ impl Session { /// Connect to an SSH server via vsock async fn connect( privkey: PrivateKey, + username: &str, cid: u32, port: u32, timeout: Duration, @@ -127,7 +135,7 @@ impl Session { let vsock_addr = VsockAddr::new(cid, port); let now = Instant::now(); - info!("🔑 Connecting via vsock"); + info!("Connecting SSH via vsock"); let mut session = loop { // Check for cancellation if cancel_token.is_cancelled() { @@ -138,90 +146,145 @@ impl Session { 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. + 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(_) => { + warn!("Timeout while connecting to VM via vsock"); + bail!( + "Timeout while connecting to VM via vsock. +Hint: Qlean uses vhost-vsock for SSH. Ensure /dev/vhost-vsock exists and the hypervisor provides a working vsock path." + ); + } + Ok(Err(ref e)) if e.raw_os_error() == Some(ERR_EACCES) => { + bail!( + "Permission denied while connecting via vsock: {e}\n\ +Hint: Ensure your user can access /dev/vhost-vsock (usually by being in the 'kvm' group) or run as root." + ); + } + 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" + // Keep this at warn level: transient vsock timing issues are expected while the VM boots. + warn!("Timeout while connecting to VM via vsock"); + bail!( + "Timeout while connecting to VM via vsock.\n\ +Hint: Qlean uses vhost-vsock for SSH. Ensure /dev/vhost-vsock exists and the hypervisor provides a working vsock path." ); - bail!("Timeout"); } continue; } - Err(ref e) => match e.kind() { + Ok(Err(ref e)) => match e.kind() { ErrorKind::TimedOut | ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset => { + | ErrorKind::ConnectionReset + | ErrorKind::NetworkUnreachable + | ErrorKind::AddrNotAvailable => { if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" + // Keep this at warn level: transient vsock timing issues are expected while the VM boots. + warn!("Timeout while connecting to VM via vsock"); + bail!( + "Timeout while connecting to VM via vsock. +Hint: Qlean uses vhost-vsock for SSH. Ensure /dev/vhost-vsock exists and the hypervisor provides a working vsock path." ); - bail!("Timeout"); } continue; } e => { - error!("Unhandled error occurred: {e}"); - bail!("Unknown error"); + error!("SSH vsock connect error: {e}"); + bail!("SSH vsock connect error: {e}"); } }, }; // 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)) => { + 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(_) => { + warn!("Timeout establishing SSH over vsock"); + bail!( + "Timeout establishing SSH handshake over vsock.\n\ +Hint: Ensure the guest exposes SSH over vsock (Qlean configures a vsock->TCP proxy via cloud-init) and that the VM finished booting." + ); + } + 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::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::UnexpectedEof => { if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" + warn!("Timeout establishing SSH over vsock"); + bail!( + "Timeout establishing SSH handshake over vsock.\n\ +Hint: Ensure the guest exposes SSH over vsock (Qlean configures a vsock->TCP proxy via cloud-init) and that the VM finished booting." ); - bail!("Timeout"); } } e => { - error!("Unhandled error occurred: {e}"); - bail!("Unknown error"); + error!("SSH handshake error: {e}"); + bail!("SSH handshake error: {e}"); } } } - Err(russh::Error::Disconnect) => { + Ok(Err(russh::Error::Disconnect)) => { if now.elapsed() > timeout { - error!( - "Reached timeout trying to connect to virtual machine via SSH, aborting" + warn!("Timeout establishing SSH over vsock (disconnect loop)"); + bail!( + "Timeout establishing SSH handshake over vsock.\n\ +Hint: Ensure the guest exposes SSH over vsock (Qlean configures a vsock->TCP proxy via cloud-init) and that the VM finished booting." ); - bail!("Timeout"); } } - Err(e) => { - error!("Unhandled error occurred: {e}"); - bail!("Unknown error"); + Ok(Err(e)) => { + error!("SSH client error: {e}"); + bail!("SSH client error: {e}"); } } }; 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 +329,33 @@ 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 } => { + // 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; + } + _ => {} } } Ok(code) @@ -318,31 +381,31 @@ 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 } => { + // 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; + } + _ => {} } } Ok((code, stdout, stderr)) @@ -354,34 +417,82 @@ impl Session { .await?; Ok(()) } + + // NOTE: TCP-based SSH is intentionally not supported. + // Qlean's E2E flow is expected to use vhost-vsock for host<->guest SSH. } /// Connect SSH and run a command that checks whether the system is ready for operation. +/// Connect SSH over vhost-vsock and run a readiness probe. +/// pub async fn connect_ssh( cid: u32, 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() { + info!("SSH connection 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)); + + info!( + "Connecting SSH via vsock cid={} port=22 as root (budget {:?})", + cid, per_attempt_timeout + ); + + 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"); + } - // Allow the --env option to work by allowing SSH to accept all sent environment variables. - // ssh.call("echo AcceptEnv * >> /etc/ssh/sshd_config").await?; + let _ = + tokio::time::timeout(ready_budget, session.call("true", cancel_token.clone())) + .await + .context("SSH readiness probe timed out")??; + + debug!("SSH command channel is ready"); + return Ok(session); + } + Err(e) => { + warn!("SSH via vsock not ready yet: {}", 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. Hint: Ensure the guest exposes SSH over vsock and finished booting." + ))) } impl Session { diff --git a/src/utils.rs b/src/utils.rs index 58a4fed..b017ee3 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,19 +129,34 @@ impl CommandExt for tokio::process::Command { } } +/// Ensure host prerequisites for running virtual machines. +/// +/// IMPORTANT: This intentionally does **not** require libguestfs tools. +/// Image creation may need `guestfish`/`virt-copy-out`, but VM launch itself +/// uses the already-extracted kernel/initrd artifacts on disk. 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?; Ok(()) } +/// Ensure prerequisites for extracting kernel/initrd from disk images. +/// +/// This is only required for distros/custom modes that need libguestfs-based +/// extraction (guestfish/virt-copy-out). Qlean relies on the host's +/// libguestfs-tools installation and does not provision fallback appliances at +/// runtime. +pub async fn ensure_extraction_prerequisites() -> Result<()> { + check_command_available("guestfish").await?; + check_command_available("virt-copy-out").await?; + Ok(()) +} + async fn check_command_available(cmd: &str) -> Result<()> { let _ = tokio::process::Command::new(cmd) .arg("--version") @@ -145,6 +166,37 @@ async fn check_command_available(cmd: &str) -> Result<()> { Ok(()) } +pub fn has_iface(name: &str) -> bool { + Path::new(&format!("/sys/class/net/{name}")).exists() +} + +pub fn bridge_conf_allows(bridge: &str) -> bool { + let conf = match std::fs::read_to_string("/etc/qemu/bridge.conf") { + Ok(c) => c, + Err(_) => return false, + }; + + for line in conf.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(rest) = line.strip_prefix("allow ") { + let allowed = rest.trim(); + if allowed == "all" || allowed == bridge { + return true; + } + } + } + + false +} + +pub fn has_vsock_support() -> bool { + Path::new("/dev/vhost-vsock").exists() +} + async fn ensure_network() -> Result<()> { let output = tokio::process::Command::new("virsh") .arg("-c") @@ -155,8 +207,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 +219,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 { diff --git a/tests/arch_image.rs b/tests/arch_image.rs new file mode 100644 index 0000000..7c30937 --- /dev/null +++ b/tests/arch_image.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use qlean::{Distro, MachineConfig, create_image, with_machine}; +use serial_test::serial; +use std::{str, time::Duration}; + +#[path = "support/e2e.rs"] +mod e2e; +#[path = "support/guestfish.rs"] +mod guestfish; +#[path = "support/logging.rs"] +mod logging; + +use e2e::ensure_vm_test_env; +use guestfish::ensure_guestfish_tools; +use logging::tracing_subscriber_init; + +#[tokio::test] +#[serial] +async fn test_arch_image_startup_flow() -> Result<()> { + tracing_subscriber_init(); + + ensure_vm_test_env()?; + + eprintln!("INFO: host checks passed"); + + ensure_guestfish_tools()?; + + eprintln!("INFO: creating image"); + let image = tokio::time::timeout( + Duration::from_secs(25 * 60), + create_image(Distro::Arch, "arch-cloudimg"), + ) + .await??; + + assert!(image.path().exists(), "qcow2 image must exist"); + assert!(image.kernel().exists(), "kernel must exist"); + assert!(image.initrd().exists(), "initrd must exist"); + eprintln!("INFO: image ready: {}", image.path().display()); + + eprintln!("INFO: starting VM and waiting for SSH"); + // Full startup flow validation + let config = MachineConfig::default(); + tokio::time::timeout(Duration::from_secs(20 * 60), async { + with_machine(&image, &config, |vm| { + Box::pin(async { + let result = vm.exec(". /etc/os-release && echo $ID").await?; + assert!(result.status.success()); + let distro_id = str::from_utf8(&result.stdout)?.trim(); + assert!( + distro_id.contains("arch"), + "unexpected distro id: {distro_id}" + ); + + // Ensure we can execute privileged operations (root or passwordless sudo). + let uid = vm.exec("id -u").await?; + assert!(uid.status.success()); + let uid_str = str::from_utf8(&uid.stdout)?.trim(); + if uid_str != "0" { + let sudo_uid = vm.exec("sudo -n id -u").await?; + assert!(sudo_uid.status.success(), "sudo must be available for E2E"); + let sudo_uid_str = str::from_utf8(&sudo_uid.stdout)?.trim(); + assert_eq!(sudo_uid_str, "0", "sudo must yield uid 0"); + } + Ok(()) + }) + }) + .await + }) + .await??; + + Ok(()) +} 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/fedora_image.rs b/tests/fedora_image.rs new file mode 100644 index 0000000..1620877 --- /dev/null +++ b/tests/fedora_image.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use qlean::{Distro, MachineConfig, create_image, with_machine}; +use serial_test::serial; +use std::{str, time::Duration}; + +#[path = "support/e2e.rs"] +mod e2e; +#[path = "support/guestfish.rs"] +mod guestfish; +#[path = "support/logging.rs"] +mod logging; + +use e2e::ensure_vm_test_env; +use guestfish::ensure_guestfish_tools; +use logging::tracing_subscriber_init; + +#[tokio::test] +#[serial] +async fn test_fedora_image_startup_flow() -> Result<()> { + tracing_subscriber_init(); + + ensure_vm_test_env()?; + + eprintln!("INFO: host checks passed"); + + ensure_guestfish_tools()?; + + eprintln!("INFO: creating image"); + let image = tokio::time::timeout( + Duration::from_secs(25 * 60), + create_image(Distro::Fedora, "fedora-cloud"), + ) + .await??; + + assert!(image.path().exists(), "qcow2 image must exist"); + assert!(image.kernel().exists(), "kernel must exist"); + assert!(image.initrd().exists(), "initrd must exist"); + eprintln!("INFO: image ready: {}", image.path().display()); + + eprintln!("INFO: starting VM and waiting for SSH"); + // Full startup flow validation + let config = MachineConfig::default(); + tokio::time::timeout(Duration::from_secs(20 * 60), async { + with_machine(&image, &config, |vm| { + Box::pin(async { + let result = vm.exec(". /etc/os-release && echo $ID").await?; + assert!(result.status.success()); + let distro_id = str::from_utf8(&result.stdout)?.trim(); + assert!( + distro_id.contains("fedora"), + "unexpected distro id: {distro_id}" + ); + + // Ensure we can execute privileged operations (root or passwordless sudo). + let uid = vm.exec("id -u").await?; + assert!(uid.status.success()); + let uid_str = str::from_utf8(&uid.stdout)?.trim(); + if uid_str != "0" { + let sudo_uid = vm.exec("sudo -n id -u").await?; + assert!(sudo_uid.status.success(), "sudo must be available for E2E"); + let sudo_uid_str = str::from_utf8(&sudo_uid.stdout)?.trim(); + assert_eq!(sudo_uid_str, "0", "sudo must yield uid 0"); + } + Ok(()) + }) + }) + .await + }) + .await??; + + Ok(()) +} diff --git a/tests/machine_pool.rs b/tests/machine_pool.rs index a0e49f3..a1b112d 100644 --- a/tests/machine_pool.rs +++ b/tests/machine_pool.rs @@ -1,8 +1,9 @@ use anyhow::Result; use qlean::{Distro, MachineConfig, create_image, with_pool}; -mod common; -use common::tracing_subscriber_init; +#[path = "support/logging.rs"] +mod logging; +use logging::tracing_subscriber_init; #[tokio::test] async fn test_ping() -> Result<()> { diff --git a/tests/single_machine.rs b/tests/single_machine.rs index 76fea78..5b75f0b 100644 --- a/tests/single_machine.rs +++ b/tests/single_machine.rs @@ -7,8 +7,9 @@ use std::{ use anyhow::Result; use qlean::{Distro, MachineConfig, create_image, with_machine}; -mod common; -use common::tracing_subscriber_init; +#[path = "support/logging.rs"] +mod logging; +use logging::tracing_subscriber_init; #[tokio::test] async fn hello() -> Result<()> { 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/support/e2e.rs b/tests/support/e2e.rs new file mode 100644 index 0000000..77f30b7 --- /dev/null +++ b/tests/support/e2e.rs @@ -0,0 +1,97 @@ +use std::{path::Path, process::Command}; + +use anyhow::{Context, Result, bail}; + +const QLEAN_BRIDGE_NAME: &str = "qlbr0"; + +fn has_cmd(cmd: &str) -> bool { + match Command::new(cmd).arg("--version").output() { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => false, + } +} + +fn ensure_vm_test_commands() -> Result<()> { + if !has_cmd("virsh") { + bail!("Missing required command: virsh (libvirt-clients)."); + } + if !has_cmd("qemu-system-x86_64") && !has_cmd("qemu-kvm") { + bail!("Missing required command: qemu-system-x86_64 (or qemu-kvm)."); + } + Ok(()) +} + +fn ensure_libvirt_system() -> Result<()> { + let output = Command::new("virsh") + .args(["-c", "qemu:///system", "list", "--all"]) + .output() + .context("failed to execute `virsh -c qemu:///system list --all`")?; + + if !output.status.success() { + bail!( + "libvirt system URI is not usable: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) +} + +fn has_iface(name: &str) -> bool { + Path::new(&format!("/sys/class/net/{name}")).exists() +} + +fn bridge_conf_allows(bridge: &str) -> bool { + let path = Path::new("/etc/qemu/bridge.conf"); + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + + contents + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .any(|line| line == "allow all" || line == format!("allow {bridge}")) +} + +pub fn ensure_vm_test_env() -> Result<()> { + ensure_vm_test_commands()?; + ensure_libvirt_system()?; + + if !Path::new("/dev/vhost-vsock").exists() { + bail!( + "Missing required device: /dev/vhost-vsock (vhost-vsock is required; no TCP fallback)." + ); + } + + if !has_iface(QLEAN_BRIDGE_NAME) { + bail!( + "Missing required bridge interface '{}'. Hint: ensure the libvirt network 'qlean' is active (virsh -c qemu:///system net-start qlean).", + QLEAN_BRIDGE_NAME + ); + } + if !bridge_conf_allows(QLEAN_BRIDGE_NAME) { + bail!( + r#"QEMU bridge helper is not configured to allow '{}'. + +Fix (run once as root): + sudo bash ./scripts/setup-host-prereqs.sh + +Or manually: + sudo mkdir -p /etc/qemu + echo "allow {}" | sudo tee /etc/qemu/bridge.conf + sudo chmod 644 /etc/qemu/bridge.conf + +Also ensure qemu-bridge-helper has CAP_NET_ADMIN (recommended): + sudo chmod u-s /usr/lib/qemu/qemu-bridge-helper + sudo setcap cap_net_admin+ep /usr/lib/qemu/qemu-bridge-helper + +Then re-run the test."#, + QLEAN_BRIDGE_NAME, + QLEAN_BRIDGE_NAME + ); + } + + Ok(()) +} diff --git a/tests/support/guestfish.rs b/tests/support/guestfish.rs new file mode 100644 index 0000000..7463ccc --- /dev/null +++ b/tests/support/guestfish.rs @@ -0,0 +1,51 @@ +use anyhow::{Context, Result, bail}; +use std::process::Command; + +fn has_cmd(cmd: &str) -> bool { + match Command::new(cmd).arg("--version").output() { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => false, + } +} + +fn combined_output(output: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + match (stdout.is_empty(), stderr.is_empty()) { + (false, false) => format!("{}\n{}", stdout, stderr), + (false, true) => stdout, + (true, false) => stderr, + (true, true) => "(no output)".to_string(), + } +} + +/// Require the host libguestfs tools/runtime used by the real extraction path. +/// +/// Reviewer feedback explicitly asked to fix the host-side libguestfs setup +/// instead of provisioning fallback appliances at runtime, so E2E checks fail +/// fast here if the host installation is incomplete. +pub fn ensure_guestfish_tools() -> Result<()> { + if !has_cmd("guestfish") { + bail!("Missing required command: guestfish (package: libguestfs-tools)."); + } + if !has_cmd("virt-copy-out") { + bail!("Missing required command: virt-copy-out (package: libguestfs-tools)."); + } + if !has_cmd("libguestfs-test-tool") { + bail!("Missing required command: libguestfs-test-tool (package: libguestfs-tools)."); + } + + let output = Command::new("libguestfs-test-tool") + .env("LIBGUESTFS_BACKEND", "direct") + .output() + .with_context(|| "failed to execute `libguestfs-test-tool`")?; + + if !output.status.success() { + bail!( + "libguestfs-test-tool failed; fix the host libguestfs-tools installation before running E2E tests:\n{}", + combined_output(&output) + ); + } + Ok(()) +} diff --git a/tests/support/logging.rs b/tests/support/logging.rs new file mode 100644 index 0000000..1941464 --- /dev/null +++ b/tests/support/logging.rs @@ -0,0 +1,20 @@ +use std::sync::Once; + +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")); + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_timer(LocalTime::rfc_3339()) + .try_init() + .ok(); + }); +} diff --git a/tests/ubuntu_image.rs b/tests/ubuntu_image.rs index 8cfa456..fdb18d3 100644 --- a/tests/ubuntu_image.rs +++ b/tests/ubuntu_image.rs @@ -1,27 +1,72 @@ use anyhow::Result; -use qlean::{Distro, create_image}; +use qlean::{Distro, MachineConfig, create_image, with_machine}; use serial_test::serial; +use std::{str, time::Duration}; -mod common; -use common::tracing_subscriber_init; +#[path = "support/e2e.rs"] +mod e2e; +#[path = "support/guestfish.rs"] +mod guestfish; +#[path = "support/logging.rs"] +mod logging; + +use e2e::ensure_vm_test_env; +use guestfish::ensure_guestfish_tools; +use logging::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?; + ensure_vm_test_env()?; + + eprintln!("INFO: host checks passed"); + + ensure_guestfish_tools()?; + + eprintln!("INFO: creating image"); + let image = tokio::time::timeout( + Duration::from_secs(15 * 60), + 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"); + eprintln!("INFO: image ready: {}", image.path().display()); + + eprintln!("INFO: starting VM and waiting for SSH"); + // Full startup flow validation + let config = MachineConfig::default(); + tokio::time::timeout(Duration::from_secs(5 * 60), async { + with_machine(&image, &config, |vm| { + Box::pin(async { + let result = vm.exec(". /etc/os-release && echo $ID").await?; + assert!(result.status.success()); + let distro_id = str::from_utf8(&result.stdout)?.trim(); + assert!( + distro_id.contains("ubuntu"), + "unexpected distro id: {distro_id}" + ); - println!("✅ Ubuntu image created successfully!"); - println!(" Image: {}", image.path().display()); - println!(" Kernel: {}", image.kernel().display()); - println!(" Initrd: {}", image.initrd().display()); + // Ensure we can execute privileged operations (root or passwordless sudo). + let uid = vm.exec("id -u").await?; + assert!(uid.status.success()); + let uid_str = str::from_utf8(&uid.stdout)?.trim(); + if uid_str != "0" { + let sudo_uid = vm.exec("sudo -n id -u").await?; + assert!(sudo_uid.status.success(), "sudo must be available for E2E"); + let sudo_uid_str = str::from_utf8(&sudo_uid.stdout)?.trim(); + assert_eq!(sudo_uid_str, "0", "sudo must yield uid 0"); + } + Ok(()) + }) + }) + .await + }) + .await??; Ok(()) }