diff --git a/Cargo.lock b/Cargo.lock index 28a9719..9e6d260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2245,6 +2245,65 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qapi" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b047adab56acc4948d4b9b58693c1f33fd13efef2d6bb5f0f66a47436ceada8" +dependencies = [ + "bytes", + "futures", + "log", + "memchr", + "qapi-qmp", + "qapi-spec", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + +[[package]] +name = "qapi-codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb959fed63a69baa2e3ae57224d885e686bc3f56c9bb3b03406969980ea57a44" +dependencies = [ + "qapi-parser", +] + +[[package]] +name = "qapi-parser" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b37f643cfdf67a409a9323334138a11636a5db5d56cedcc780d7a82a7fb7659" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "qapi-qmp" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45303cac879d89361cad0287ae15f9ae1e7799b904b474152414aeece39b9875" +dependencies = [ + "qapi-codegen", + "qapi-spec", + "serde", +] + +[[package]] +name = "qapi-spec" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e6bdbbe5d13015b21a49a778a29ae3cee9c450c3154e1648aed670d57fe5ba" +dependencies = [ + "base64", + "serde", + "serde_json", +] + [[package]] name = "qlean" version = "0.3.0" @@ -2258,6 +2317,7 @@ dependencies = [ "indicatif", "kvm-ioctls", "nanoid", + "qapi", "rand 0.9.2", "reqwest", "russh", diff --git a/Cargo.toml b/Cargo.toml index 1325bce..65e762e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ version = "0.3.0" authors = ["jl.jiang "] categories = ["development-tools::testing", "virtualization"] description = "A system-level isolation testing library based on QEMU/KVM." -documentation = "https://buck2hub.com/docs/qlean" edition = "2024" exclude = ["/.github"] keywords = ["isolation", "testing", "kvm", "qemu"] @@ -21,6 +20,7 @@ futures = "0.3" indicatif = "0.18.4" kvm-ioctls = "0.24.0" nanoid = "0.4.0" +qapi = { version = "0.15.0", features = ["qmp", "async-tokio-all"] } rand = "0.9.2" reqwest = { version = "0.13.1", features = ["stream"] } russh = "0.55.0" diff --git a/README.md b/README.md index 5de87c8..9717c12 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,47 @@ # Qlean -**Qlean** is a system-level isolation testing library based on QEMU/KVM, providing complete virtual machine isolation environments for Rust projects. +**Qlean** is a system-level isolation testing library built on QEMU/KVM. It spins up lightweight VMs in your Rust tests so privileged or risky operations stay off the host. ## Overview -Qlean provides a comprehensive testing solution for projects requiring system-level isolation by launching lightweight virtual machines during tests. It addresses two major challenges: +Qlean targets two common needs in system-level testing: **1. Complete Resource Isolation** -Many projects require root privileges or direct manipulation of system-level resources. Traditional single-machine tests can easily crash the host system if tests fail. Qlean uses virtual machine isolation to completely isolate these operations within the VM, ensuring host system stability. +Some tests need root privileges or direct access to kernel interfaces. Running them on the host can leave the machine in a bad state when a test fails. Qlean runs each test in its own VM so failures stay contained and the host stays stable. -**2. Convenient Multi-Machine Testing** +**2. Convenient Distributed Testing** -For projects requiring multi-machine collaboration, Qlean provides a simple API that allows you to easily create and manage multiple VM instances in test code without complex infrastructure configuration. +For distributed or multi-node scenarios, Qlean lets you create and coordinate several VMs from test code—no separate cluster setup or orchestration layer required. ## Key Features - 🔒 **Complete Isolation**: Based on QEMU/KVM, providing full virtual machine isolation -- 🔄 **Multi-Machine Support**: Easily create and manage multiple virtual machines +- 🔄 **Distributed Testing**: Easily create and manage multiple virtual machines - 🛡️ **RAII-style Interface**: Automatic resource management ensures VMs are properly cleaned up -- 📦 **Out-of-the-Box**: Automated image downloading and extraction, no manual configuration needed -- 🐧 **Linux Native**: Native support for Linux hosts with multiple Linux distributions +- 📦 **Out-of-the-Box**: Automated image downloading with verification, no manual configuration needed +- 🐧 **Linux Native**: Native support for Linux hosts with multiple guest distributions and architectures ## Usage ### Host Setup -#### Install CLI utils +#### Install CLI tools -Before using Qlean, ensure that QEMU, guestfish, libvirt, libguestfs-tools and some other utils are properly installed on your Linux host. You can verify the installation with the following commands: - -```bash -qemu-system-x86_64 --version -qemu-img --version -virsh --version -xorriso --version -``` +Install and configure QEMU, libvirt, and xorriso on your Linux host before using Qlean. On Debian or Ubuntu, see [the setup guide](https://buck2hub.com/docs/qlean/setup) for step-by-step instructions. #### Configure qemu-bridge-helper Qlean uses `qemu-bridge-helper` to manage networking for multiple virtual machines, so it requires proper configuration. -The `CAP_NET_ADMIN` capability needs to be set on for the default network helper: +Grant `CAP_NET_ADMIN` to the default network helper: ```bash sudo chmod u-s /usr/lib/qemu/qemu-bridge-helper sudo setcap cap_net_admin+ep /usr/lib/qemu/qemu-bridge-helper ``` -The ACL mechanism enforced by `qemu-bridge-helper` defaults to blacklisting all users, so the `qlbr0` bridge created by qlean must be explicitly allowed: +`qemu-bridge-helper` denies all bridges by default, so you must allow the `qlbr0` bridge that Qlean creates: ```bash sudo mkdir -p /etc/qemu @@ -64,25 +57,55 @@ Add the dependency to your `Cargo.toml`: [dev-dependencies] qlean = "0.3" tokio = { version = "1", features = ["full"] } +tracing-indicatif = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time"] } +``` + +Qlean uses [`tracing`](https://docs.rs/tracing) and [`indicatif`](https://docs.rs/indicatif) for structured logs and progress bars (for example while downloading images). To see that output in your own tests, add `tracing-indicatif` and `tracing-subscriber` as above and install a global subscriber once per process. A helper guarded with `std::sync::Once` works well when many tests share the same setup: + +```rust +use std::sync::Once; + +use tracing_indicatif::IndicatifLayer; +use tracing_subscriber::{ + EnvFilter, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt, +}; + +static INIT: Once = Once::new(); + +pub fn init_tracing() { + INIT.call_once(|| { + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,qlean=info")); + let indicatif_layer = IndicatifLayer::new(); + tracing_subscriber::registry() + .with(env_filter) + .with( + tracing_subscriber::fmt::layer() + .with_timer(LocalTime::rfc_3339()) + .with_writer(indicatif_layer.get_stderr_writer()), + ) + .with(indicatif_layer) + .try_init() + .ok(); + }); +} ``` +Call `init_tracing()` at the start of each test (or from a shared test harness). Adjust verbosity with `RUST_LOG`, for example `RUST_LOG=debug,qlean=trace`. + ### Basic Example -Here's a simple test example with single machine: +A minimal single-VM test: ```rust use anyhow::Result; -use qlean::{Distro, Image, ImageConfig, GuestArch, MachineConfig, with_machine}; +use qlean::{Image, ImageConfig, MachineConfig, with_machine}; #[tokio::test] async fn test_with_vm() -> Result<()> { // Create VM image and config - let image = Image::new( - ImageConfig::default() - .with_arch(GuestArch::Amd64), - .with_distro(Distro::Debian) - ) - .await?; + let image = Image::new(ImageConfig::default()).await?; let config = MachineConfig::default(); // Execute tests in the virtual machine @@ -102,28 +125,23 @@ async fn test_with_vm() -> Result<()> { } ``` -The following is another example of a multi-machine test: +A distributed test with two VMs on the same virtual network: ```rust use anyhow::Result; -use qlean::{Distro, Image, ImageConfig, GuestArch, MachineConfig, create_image, with_pool}; +use qlean::{Image, ImageConfig, MachineConfig, with_pool}; #[tokio::test] async fn test_ping() -> Result<()> { with_pool(|pool| { Box::pin(async { // Create VM image and config - let image = Image::new( - ImageConfig::default() - .with_distro(Distro::Debian) - .with_arch(GuestArch::Amd64), - ) - .await?; + let image = Image::new(ImageConfig::default()).await?; let config = MachineConfig::default(); // Add machines to the pool and initialize them concurrently - pool.add("alice".to_string(), &image, &config).await?; - pool.add("bob".to_string(), &image, &config).await?; + pool.add("alice", &image, &config).await?; + pool.add("bob", &image, &config).await?; pool.init_all().await?; // Get mutable references to both machines by name @@ -147,11 +165,11 @@ async fn test_ping() -> Result<()> { } ``` -For more examples, please refer to the [tests](tests) directory. +More examples live in the [tests](tests) directory. ## Network Configuration -Qlean uses a dedicated libvirt virtual network to provide isolated, reproducible networking for test VMs. The default network definition is stored at `~/.local/share/qlean/network.xml` as follows: +Qlean uses a dedicated libvirt virtual network for isolated, reproducible connectivity between test VMs. The default definition is written to `~/.local/share/qlean/network.xml`: ```xml @@ -166,68 +184,102 @@ Qlean uses a dedicated libvirt virtual network to provide isolated, reproducible ``` -This configuration defines a **NAT-based** virtual network named `qlean` (used internally by libvirt) that creates a Linux bridge interface called `qlbr0`. The bridge is assigned the IP address `192.168.221.1` and serves as the gateway for a `/24` subnet (`192.168.221.0/24`). A built-in DHCP server automatically assigns IP addresses to virtual machines in the range `192.168.221.2` to `192.168.221.254`, enabling seamless network connectivity between the host, test VMs, and—via NAT—the external network. +This defines a **NAT** network named `qlean` in libvirt, backed by the Linux bridge `qlbr0` at `192.168.221.1`. DHCP hands out addresses in `192.168.221.2`–`192.168.221.254` on the `192.168.221.0/24` subnet so VMs can reach each other, the host, and the outside world through NAT. > [!NOTE] -> If the `192.168.221.0/24` subnet conflicts with your local network, you may edit the configuration file to use a different IP range,but keep the `qlean` and `` unchanged to ensure compatibility with qlean's internal logic. +> If `192.168.221.0/24` conflicts with your LAN, change the IP range in that file, but leave `qlean` and `` as they are—Qlean expects those identifiers. ## API Reference ### Top-Level Interface -- `is_kvm_available()` - Check if KVM is available on host. -- `with_machine(image, config, f)` - Execute an async closure in a virtual machine with automatic resource cleanup -- `with_pool(f)` - Execute an async closure in a machine pool with automatic resource cleanup -- `MachineConfig` - Configuration for virtual machine resources (CPU, memory, disk) +- `is_kvm_available()` - Check if KVM is available on the host. +- `with_machine(image, config, f)` — Run an async closure with one VM; initializes on entry and shuts down on exit. +- `with_pool(f)` — Run an async closure with a `MachinePool`; shuts down all pool members on exit. + +- `ImageConfig` - Configuration for a virtual machine image. + + ```rust + pub struct ImageConfig { + /// Architecture of the image, defaults to `GuestArch::Amd64`. + pub arch: GuestArch, + /// Distribution of the image, defaults to `Distro::Debian`. + pub distro: Distro, + /// Source of the image, it can be a URL or a local file path. + /// If provided, the image will be fetched from the source and verified against the digest. + pub source: Option, + /// Digest of the image, in the form of `sha256:` or `sha512:`. + /// It should be provided along with the source. + pub digest: Option, + /// Whether to clear the image after use, defaults to `false`. + /// It is useful for custom images that are not expected to be used again. + pub clear: bool, + } + ``` + +- `MachineConfig` - Configuration for a virtual machine. ```rust pub struct MachineConfig { - pub core: u32, // Number of CPU cores - pub mem: u32, // Memory size in MB - pub disk: Option, // Disk size in GB (optional) - pub clear: bool, // Clear resources after use + /// Number of CPU cores, defaults to `2`. + pub core: u32, + /// Memory in MB, defaults to `4096`. + pub mem: u32, + /// Disk size in GB, defaults to `None`. + /// If provided, the image will be resized to the specified size. + pub disk: Option, + /// Whether to clear the runtime directory after use, defaults to `true`. + pub clear: bool, + /// Timeout in seconds for SSH over vsock to wait during launch, + /// defaults to `180` with KVM and `300` under TCG. + pub ssh_timeout: Option, } ``` +### Image Interface + +- `Image::new(config)` - Create a new image with specified configuration. + ### Machine Core Interface -- `Machine::new(image, config)` - Create a new machine instance -- `Machine::init()` - Initialize the machine (first boot with cloud-init) -- `Machine::spawn()` - Start the machine (normal boot) -- `Machine::exec(command)` - Execute a command in the VM and return the output -- `Machine::shutdown()` - Gracefully shutdown the virtual machine -- `Machine::upload(src, dst)` - Upload a file or directory to the VM -- `Machine::download(src, dst)` - Download a file or directory from the VM -- `Machine::get_ip()` - Get the IP address of the VM +- `Machine::new(image, config)` - Create a new machine instance. +- `Machine::init()` - Initialize the machine (first boot with cloud-init). +- `Machine::spawn()` - Start the machine (normal boot). +- `Machine::exec(command)` - Execute a command in the VM and return the output. +- `Machine::shutdown()` - Gracefully shutdown the virtual machine. +- `Machine::upload(src, dst)` - Upload a file or directory to the VM. +- `Machine::download(src, dst)` - Download a file or directory from the VM. +- `Machine::get_ip()` - Get the IP address of the VM. +- `Machine::is_running()` - Check if the VM is currently running. ### Machine Pool Interface -- `MachinePool::new()` - Create a new, empty machine pool -- `MachinePool::add(name, image, config)` - Add a new machine instance to the pool -- `MachinePool::get(name)` - Get a machine instance by the name -- `MachinePool::init_all()` - Initialize all machines in the pool concurrently -- `MachinePool::spawn_all()` - Spawn all machines in the pool concurrently -- `MachinePool::shutdown_all()` - Shutdown all machines in the pool concurrently +- `MachinePool::new()` - Create a new, empty machine pool. +- `MachinePool::add(name, image, config)` - Add a new machine instance to the pool. +- `MachinePool::get(name)` - Get a machine instance by the name. +- `MachinePool::init_all()` - Initialize all machines in the pool concurrently. +- `MachinePool::spawn_all()` - Spawn all machines in the pool concurrently. +- `MachinePool::shutdown_all()` - Shutdown all machines in the pool concurrently. ### std::fs Compatible Interface The following methods provide filesystem operations compatible with `std::fs` semantics: -- `Machine::copy(from, to)` - Copy a file within the VM -- `Machine::create_dir(path)` - Create a directory -- `Machine::create_dir_all(path)` - Create a directory and all missing parent directories -- `Machine::exists(path)` - Check if a path exists -- `Machine::hard_link(src, dst)` - Create a hard link -- `Machine::metadata(path)` - Get file/directory metadata -- `Machine::read(path)` - Read file contents as bytes -- `Machine::read_dir(path)` - Read directory entries -- `Machine::read_link(path)` - Read symbolic link target -- `Machine::read_to_string(path)` - Read file contents as string -- `Machine::remove_dir_all(path)` - Remove a directory after removing all its contents -- `Machine::remove_file(path)` - Remove a file -- `Machine::rename(from, to)` - Rename or move a file/directory -- `Machine::set_permissions(path, perm)` - Set file/directory permissions -- `Machine::write(path, contents)` - Write bytes to a file +- `Machine::copy(from, to)` - Copy a file within the VM. +- `Machine::create_dir(path)` - Create a directory. +- `Machine::create_dir_all(path)` - Create a directory and all missing parent directories. +- `Machine::exists(path)` - Check if a path exists. +- `Machine::hard_link(src, dst)` - Create a hard link. +- `Machine::metadata(path)` - Get file/directory metadata. +- `Machine::read(path)` - Read file contents as bytes. +- `Machine::read_dir(path)` - Read directory entries. +- `Machine::read_link(path)` - Read symbolic link target. +- `Machine::read_to_string(path)` - Read file contents as string. +- `Machine::remove_dir_all(path)` - Remove a directory after removing all its contents. +- `Machine::remove_file(path)` - Remove a file. +- `Machine::rename(from, to)` - Rename or move a file/directory. +- `Machine::set_permissions(path, perm)` - Set file/directory permissions. +- `Machine::write(path, contents)` - Write bytes to a file. ## License diff --git a/src/image.rs b/src/image.rs index 937ae30..c2959cb 100644 --- a/src/image.rs +++ b/src/image.rs @@ -14,15 +14,19 @@ use tracing_indicatif::span_ext::IndicatifSpanExt; use crate::utils::{QleanDirs, qlean_user_agent}; +/// Virtual machine image. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Image { - pub name: String, - pub path: PathBuf, - pub arch: GuestArch, - pub distro: Distro, - pub digest: (ShaType, String), + name: String, + path: PathBuf, + arch: GuestArch, + distro: Distro, + pub(crate) digest: (ShaType, String), + #[serde(default)] + pub(crate) clear: bool, } +/// Distribution of the image: Debian, Ubuntu, Fedora or Arch. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Default)] pub enum Distro { #[default] @@ -32,6 +36,7 @@ pub enum Distro { Arch, } +/// Guest architecture: amd64, aarch64 or riscv64. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Default)] pub enum GuestArch { #[default] @@ -40,15 +45,16 @@ pub enum GuestArch { Riscv64, } +/// Type of hash: SHA256 or SHA512. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] -pub enum ShaType { +pub(crate) enum ShaType { Sha256, Sha512, } -/// Source of a file: URL or local file path +/// Source of a image: URL or local file path. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ImageSource { +pub(crate) enum ImageSource { Url(String), LocalPath(PathBuf), } @@ -59,23 +65,33 @@ impl Default for ImageSource { } } +/// Configuration for a virtual machine image. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ImageConfig { + /// Architecture of the image, defaults to `GuestArch::Amd64`. pub arch: GuestArch, + /// Distribution of the image, defaults to `Distro::Debian`. pub distro: Distro, + /// Source of the image, it can be a URL or a local file path. If provided, the image will be fetched from the source and verified against the digest. pub source: Option, + /// Digest of the image, in the form of `sha256:` or `sha512:`. It should be provided along with the source. pub digest: Option, + /// Whether to clear the image after use, defaults to `false`. It is useful for custom images that are not expected to be used again. + pub clear: bool, } impl ImageConfig { + /// Set the distribution of the image. pub fn with_distro(self, distro: Distro) -> Self { Self { distro, ..self } } + /// Set the architecture of the image. pub fn with_arch(self, arch: GuestArch) -> Self { Self { arch, ..self } } + /// Set the source of the image. pub fn with_source(self, source: String) -> Self { Self { source: Some(source), @@ -83,6 +99,7 @@ impl ImageConfig { } } + /// Set the digest of the image. pub fn with_digest(self, digest: String) -> Self { Self { digest: Some(digest), @@ -90,8 +107,13 @@ impl ImageConfig { } } + /// Set whether to clear the image after use. + pub fn with_clear(self, clear: bool) -> Self { + Self { clear, ..self } + } + /// `source` and `digest` must both be set or both omitted. - pub fn validate(&self) -> Result<()> { + fn validate(&self) -> Result<()> { anyhow::ensure!( (self.source.is_none() && self.digest.is_none()) || (self.source.is_some() && self.digest.is_some()), @@ -160,7 +182,7 @@ fn checksum_text_payload(raw: &str) -> &str { /// 2) "SHA256 () = " / "SHA512 () = " /// /// Comment lines (`#` ...) and PGP-signed-message wrappers are ignored. -pub fn find_hash_for_file(checksums_text: &str, filename: &str) -> Option { +fn find_hash_for_file(checksums_text: &str, filename: &str) -> Option { let payload = checksum_text_payload(checksums_text); for line in payload.lines() { @@ -359,7 +381,7 @@ impl StreamingHasher { } /// Compute a streaming hash over `path` using sync I/O on a blocking thread. -pub async fn compute_hash(path: &Path, hash_type: ShaType) -> Result { +async fn compute_hash(path: &Path, hash_type: ShaType) -> Result { let path = path.to_path_buf(); tokio::task::spawn_blocking(move || { @@ -566,23 +588,21 @@ async fn fetch_from_source( } impl Image { - pub fn name(&self) -> &str { - &self.name - } - - pub fn path(&self) -> &PathBuf { + pub(crate) fn path(&self) -> &PathBuf { &self.path } - pub fn guest_arch(&self) -> GuestArch { + pub(crate) fn guest_arch(&self) -> GuestArch { self.arch } } impl Image { - /// Create a new image using an explicit vendor value (supports per-image `arch` and optional `source` + `digest` overrides on built-in distros). + /// Create a new image with specified configuration. pub async fn new>(config: C) -> Result { let config = config.as_ref(); + config.validate().context("invalid image config")?; + let override_source = config.source.as_deref().map(resolve_image_source); let override_digest = config .digest @@ -627,9 +647,12 @@ impl Image { arch: config.arch, distro: config.distro, digest: image_digest, + clear: config.clear, }; - image.save(&name).await?; + if !config.clear { + image.save(&name).await?; + } Ok(image) } @@ -674,6 +697,14 @@ impl Image { } } +impl Drop for Image { + fn drop(&mut self) { + if self.clear { + let _ = std::fs::remove_file(&self.path); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -749,6 +780,7 @@ f0442f3cd0087a609ecd5241109ddef0cbf4a1e05372e13d82c97fc77b35b2d8ecff85aea6770915 distro: Distro::Debian, source: Some("https://example.com/image.qcow2".to_string()), digest: Some("sha256:abcdef123456".to_string()), + clear: false, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 8dd22c3..6a45cf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,22 @@ +//! Qlean is a system-level isolation testing library built on QEMU/KVM. +//! It spins up lightweight VMs in your Rust tests so privileged or risky operations stay off the host. +//! +//! # Features +//! +//! - **Isolation**: Each test runs in its own VM, so failures don't bring down the host. +//! - **Distributed testing**: Easily create and manage multiple virtual machines from test code. +//! - **RAII-style interface**: Automatic resource management ensures VMs are properly cleaned up. +//! - **Out-of-the-box**: Automated image downloading with verification, no manual configuration needed. +//! - **Linux native**: Native support for Linux hosts with multiple guest distributions and architectures. +//! +//! # Examples +//! +//! Examples can be found in the [tests](https://github.com/buck2hub/qlean/tree/main/tests) directory. +//! +//! # Getting Started +//! +//! For a quick start, see . + use std::future::Future; use std::pin::Pin; @@ -10,6 +29,7 @@ mod image; mod machine; mod pool; mod qemu; +mod qmp; mod ssh; mod utils; @@ -18,12 +38,10 @@ pub use image::Distro; pub use image::GuestArch; pub use image::Image; pub use image::ImageConfig; -pub use image::ImageSource; -pub use image::ShaType; pub use machine::{Machine, MachineConfig}; pub use pool::MachinePool; -/// Check if KVM is available. +/// Check if KVM is available on the host. pub fn is_kvm_available() -> bool { #[cfg(not(target_os = "linux"))] { @@ -33,7 +51,7 @@ pub fn is_kvm_available() -> bool { Kvm::new().is_ok() } -/// Execute a closure with a machine. +/// Execute a closure with a virtual machine. pub async fn with_machine<'a, F, R>(image: &'a Image, config: &'a MachineConfig, f: F) -> Result where F: for<'b> FnOnce(&'b mut Machine) -> Pin> + 'b>>, @@ -53,7 +71,7 @@ where result } -/// Execute a closure with a machine pool. +/// Execute a closure with a virtual machine pool. pub async fn with_pool(f: F) -> Result where F: for<'a> FnOnce(&'a mut MachinePool) -> Pin> + 'a>>, diff --git a/src/machine.rs b/src/machine.rs index a8f9516..346d850 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -23,10 +23,12 @@ use crate::{ image::{GuestArch, Image}, is_kvm_available, qemu::launch_qemu, + qmp, ssh::{PersistedSshKeypair, Session, connect_ssh, get_ssh_key}, utils::{CommandExt, HEX_ALPHABET, QleanDirs, gen_random_mac, get_free_cid}, }; +/// Virtual machine. pub struct Machine { id: String, image: MachineImage, @@ -49,26 +51,62 @@ pub struct Machine { } #[derive(Clone)] -pub struct MachineImage { +pub(crate) struct MachineImage { pub overlay: PathBuf, pub arch: GuestArch, pub seed: PathBuf, } +/// Configuration for a virtual machine. #[derive(Clone, Debug)] pub struct MachineConfig { - /// Number of CPU cores + /// Number of CPU cores, defaults to `2`. pub core: u32, - /// Memory in MB + /// Memory in MB, defaults to `4096`. pub mem: u32, - /// Disk size in GB (optional) + /// Disk size in GB, defaults to `None`. If provided, the image will be resized to the specified size. pub disk: Option, - /// Clear after use + /// Whether to clear the runtime directory after use, defaults to `true`. pub clear: bool, + /// Timeout in seconds for SSH over vsock to wait during launch, defaults to `180` with KVM and `300` under TCG. + pub ssh_timeout: Option, +} + +impl MachineConfig { + /// Set the number of CPU cores. + pub fn with_core(self, core: u32) -> Self { + Self { core, ..self } + } + + /// Set the memory in MB. + pub fn with_mem(self, mem: u32) -> Self { + Self { mem, ..self } + } + + /// Set the disk size in GB. + pub fn with_disk(self, disk: u32) -> Self { + Self { + disk: Some(disk), + ..self + } + } + + /// Set the timeout in seconds for SSH over vsock to wait during launch. + pub fn with_timeout(self, ssh_timeout: u64) -> Self { + Self { + ssh_timeout: Some(ssh_timeout), + ..self + } + } + + /// Set whether to clear the runtime directory after use. + pub fn with_clear(self, clear: bool) -> Self { + Self { clear, ..self } + } } #[derive(Serialize, Deserialize, Debug)] -pub struct MetaData { +struct MetaData { #[serde(rename = "instance-id")] pub instance_id: String, #[serde(rename = "local-hostname")] @@ -76,14 +114,13 @@ pub struct MetaData { } #[derive(Serialize, Deserialize, Debug)] -pub struct UserData { +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. + /// 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>, @@ -92,8 +129,7 @@ pub struct UserData { /// 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. + /// 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, @@ -103,7 +139,7 @@ pub struct UserData { } #[derive(Serialize, Deserialize, Debug)] -pub struct CloudInitWriteFile { +struct CloudInitWriteFile { pub path: String, pub content: String, @@ -121,13 +157,27 @@ impl Default for MachineConfig { mem: 4096, disk: None, clear: true, + ssh_timeout: None, } } } +fn resolve_ssh_timeout(config: &MachineConfig) -> Duration { + config + .ssh_timeout + .map(Duration::from_secs) + .unwrap_or_else(|| { + if is_kvm_available() { + Duration::from_secs(180) + } else { + Duration::from_secs(300) + } + }) +} + // Core methods for Machine impl Machine { - /// Create a new Machine instance + /// Create a new Machine instance with specified image and configuration. pub async fn new(image: &Image, config: &MachineConfig) -> Result { // Prepare run directory let dirs = QleanDirs::new()?; @@ -177,17 +227,12 @@ impl Machine { debug!("Writing cloud-init meta-data:\n{}", meta_data_str); tokio::fs::write(seed_dir.join("meta-data"), meta_data_str).await?; - // Enable an SSH path over vhost-vsock without requiring OpenSSH to accept an AF_VSOCK - // socket directly. - // - // VSOCK-only: provide SSH access exclusively via vhost-vsock. + // Enable an SSH path over vhost-vsock without requiring OpenSSH to accept an AF_VSOCK socket directly. // // 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. + // 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). + // 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 @@ -368,7 +413,7 @@ StandardError=journal }) } - /// Initialize the machine (first boot) + /// Initialize the machine (first boot with cloud-init). pub async fn init(&mut self) -> Result<()> { info!("🚀 Initializing VM-{}", self.id); @@ -403,7 +448,7 @@ StandardError=journal Ok(()) } - /// Spawn the machine (normal boot) + /// Spawn the machine (normal boot). pub async fn spawn(&mut self) -> Result<()> { info!("🔥 Spawning VM-{}", self.id); @@ -418,7 +463,7 @@ StandardError=journal Ok(()) } - /// Execute a command on the machine and return the output + /// Execute a command on the machine and return the output. pub async fn exec>(&mut self, cmd: S) -> Result { let cmd_ref = cmd.as_ref(); info!("🧬 Executing command `{}` on VM-{}", cmd_ref, self.id); @@ -441,92 +486,67 @@ StandardError=journal } } - /// Shutdown the machine + /// Shutdown the machine. pub async fn shutdown(&mut self) -> Result<()> { - if let Some(ssh) = self.ssh.as_mut() { - // 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); + if self.pid.is_none() && self.ssh.is_none() { + bail!("Machine is not running"); + } - // Tell the QEMU handler it's now fine to wait for exit. - self.qemu_should_exit.store(true, Ordering::SeqCst); + info!("🔌 Shutting down VM-{}", self.id); - // Ignore whatever error we might get from this as we want to close the connection at this - // point anyway. - let _ = ssh.close().await; + if self.pid.is_some() { + let socket_path = qmp::qmp_socket_path(&self.id)?; + if let Err(e) = qmp::powerdown(&socket_path).await { + debug!("QMP system-powerdown failed during teardown: {e}"); + } + } - // Wait for QEMU process to actually exit - if let Some(pid) = self.pid { - debug!("Waiting for QEMU process {} to exit", pid); - let max_wait_time = Duration::from_secs(30); - let poll_interval = Duration::from_millis(100); - let start = std::time::Instant::now(); + self.qemu_should_exit.store(true, Ordering::SeqCst); - loop { - // Check if process is still running - let process_exists = std::path::Path::new(&format!("/proc/{}", pid)).exists(); + if let Some(ssh) = self.ssh.as_mut() { + let _ = ssh.close().await; + } - if !process_exists { - debug!("QEMU process {} has exited", pid); - break; - } + if let Some(pid) = self.pid { + debug!("Waiting for QEMU process {} to exit", pid); + let max_wait_time = Duration::from_secs(30); + let poll_interval = Duration::from_millis(100); + let start = std::time::Instant::now(); - if start.elapsed() > max_wait_time { - info!( - "QEMU process {} did not exit within timeout, force killing", - pid - ); - let _ = std::process::Command::new("kill") - .arg("-9") - .arg(pid.to_string()) - .output(); - break; - } + loop { + if !std::path::Path::new(&format!("/proc/{pid}")).exists() { + debug!("QEMU process {} has exited", pid); + break; + } - tokio::time::sleep(poll_interval).await; + if start.elapsed() > max_wait_time { + info!( + "QEMU process {} did not exit within timeout, force killing", + pid + ); + let _ = std::process::Command::new("kill") + .arg("-9") + .arg(pid.to_string()) + .output(); + break; } - } - // Clean up runtime files - let dirs = QleanDirs::new()?; - let pid_file_path = dirs.runs.join(&self.id).join("qemu.pid"); - let _ = tokio::fs::remove_file(pid_file_path).await; - self.ssh = None; - self.pid = None; - self.ssh_cancel_token = None; - self.ip = None; - - Ok(()) - } else { - Err(anyhow::anyhow!("SSH session not established")) + tokio::time::sleep(poll_interval).await; + } } + + let dirs = QleanDirs::new()?; + let pid_file_path = dirs.runs.join(&self.id).join("qemu.pid"); + let _ = tokio::fs::remove_file(pid_file_path).await; + self.ssh = None; + self.pid = None; + self.ssh_cancel_token = None; + self.ip = None; + + Ok(()) } - /// Upload file or directory to the machine + /// Upload file or directory to the machine. pub async fn upload, Q: AsRef>( &mut self, local_path: P, @@ -627,7 +647,7 @@ StandardError=journal Ok(()) } - /// Download file or directory from the machine + /// Download file or directory from the machine. pub async fn download, Q: AsRef>( &mut self, remote_path: P, @@ -739,7 +759,7 @@ StandardError=journal Ok((ssh, cancel_token)) } - /// Get the IP address of the machine + /// Get the IP address of the machine. pub async fn get_ip(&mut self) -> Result { if let Some(ip) = &self.ip { Ok(ip.to_owned()) @@ -751,49 +771,30 @@ StandardError=journal } } - /// Check if the machine is currently running - pub(crate) async fn is_running(&self) -> Result { - if let Some(pid) = self.pid { - let process_exists = std::path::Path::new(&format!("/proc/{}", pid)).exists(); - let process_running = if process_exists { - // Further check if the process is a QEMU process - let cmdline_path = format!("/proc/{}/cmdline", pid); - if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) { - cmdline.contains("qemu-system") - } else { - false - } - } else { - false - }; - Ok(process_running) - } else { - Ok(false) + /// Check if the machine is currently running. + pub async fn is_running(&self) -> Result { + if self.pid.is_none() { + return Ok(false); } + let socket_path = qmp::qmp_socket_path(&self.id)?; + Ok(qmp::query_running(&socket_path).await.unwrap_or(false)) } - /// Launch QEMU and connect SSH concurrently + /// Launch QEMU and connect SSH concurrently. async fn launch(&mut self, is_init: bool) -> Result<()> { debug!( "SSH command for manual debugging:\nssh root@vsock/{} -i {:?}", self.cid, self.keypair.privkey_path, ); - // SSH reachability can be slow on first boot (cloud-init + sshd startup), especially on - // slower disks or under nested virtualization. - let ssh_timeout = if is_kvm_available() { - Duration::from_secs(180) - } else { - Duration::from_secs(300) - }; + let ssh_timeout = resolve_ssh_timeout(&self.config); // 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. + // 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::() @@ -826,10 +827,11 @@ StandardError=journal .output(); } - // Create a fresh cancellation token per launch. - let launch_cancel = CancellationToken::new(); - self.ssh_cancel_token = Some(launch_cancel.clone()); - + let cancel_token = self + .ssh_cancel_token + .as_ref() + .expect("Machine not initialized or spawned") + .clone(); self.qemu_should_exit.store(false, Ordering::SeqCst); let qemu_params = crate::qemu::QemuLaunchParams { cid: self.cid, @@ -838,7 +840,7 @@ StandardError=journal vmid: self.id.to_owned(), is_init, mac_address: self.mac_address.to_owned(), - cancel_token: launch_cancel.clone(), + cancel_token: cancel_token.clone(), expected_to_exit: self.qemu_should_exit.clone(), }; @@ -850,18 +852,17 @@ StandardError=journal self.cid, ssh_timeout, self.keypair.to_owned(), - launch_cancel.clone(), + cancel_token.clone(), self.mac_address.to_owned(), )); - // 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(); + // QEMU completed or errored, cancel SSH task + cancel_token.cancel(); match result { Ok(Ok(())) => bail!("QEMU exited unexpectedly"), Ok(Err(e)) => bail!(e), @@ -872,13 +873,13 @@ StandardError=journal match ssh_result { Ok(session) => { + // SSH completed, QEMU continues running 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(); + // SSH failed, QEMU should exit + // Here we proactively terminate QEMU process to avoid leaving a zombie process, but we don't set the flag `qemu_should_exit` to true because the failure of SSH is unexpected. if let Some(pid) = self.pid { terminate_qemu(pid).await; } @@ -934,7 +935,7 @@ impl Machine { Ok(()) } - /// Creates a new, empty directory at the provided path + /// Creates a new, empty directory at the provided path. pub async fn create_dir>(&mut self, path: P) -> Result<()> { let path = path.as_ref(); let (ssh, _) = self.get_ssh()?; @@ -1049,7 +1050,7 @@ impl Machine { Ok(()) } - /// Renames a file or directory to a new name + /// Renames a file or directory to a new name. pub async fn rename, Q: AsRef>(&mut self, from: P, to: Q) -> Result<()> { let from = from.as_ref(); let to = to.as_ref(); diff --git a/src/pool.rs b/src/pool.rs index 9b416bb..11dcdd4 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -5,6 +5,7 @@ use tokio::sync::Mutex; use crate::{Image, Machine, MachineConfig}; +/// Pool to manage multiple virtual machines. pub struct MachinePool { pool: HashMap>, } @@ -24,12 +25,18 @@ impl MachinePool { } /// Add a new machine to the pool. - pub async fn add(&mut self, name: String, image: &Image, config: &MachineConfig) -> Result<()> { - if self.pool.contains_key(&name) { + pub async fn add( + &mut self, + name: impl AsRef, + image: &Image, + config: &MachineConfig, + ) -> Result<()> { + let name = name.as_ref(); + if self.pool.contains_key(name) { bail!("Machine with name '{}' already exists in the pool", name); } let machine = Machine::new(image, config).await?; - self.pool.insert(name, Mutex::new(machine)); + self.pool.insert(name.to_owned(), Mutex::new(machine)); Ok(()) } diff --git a/src/qemu.rs b/src/qemu.rs index 030e001..936415c 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -41,7 +41,7 @@ fn host_arch() -> Option { } } -pub struct QemuLaunchParams { +pub(crate) struct QemuLaunchParams { pub expected_to_exit: Arc, pub cid: u32, pub image: MachineImage, @@ -52,7 +52,7 @@ pub struct QemuLaunchParams { pub mac_address: String, } -pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { +pub(crate) async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { // Prepare QEMU command let mut qemu_cmd = tokio::process::Command::new(qemu_system_program(params.image.arch)); if params.image.arch == GuestArch::Amd64 { @@ -68,6 +68,23 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { ), ]); + let dirs = QleanDirs::new()?; + let run_dir = dirs.runs.join(¶ms.vmid); + let qmp_socket = run_dir.join("qmp.sock"); + if qmp_socket.exists() { + let _ = std::fs::remove_file(&qmp_socket); + } + + qemu_cmd.args([ + "-chardev", + &format!( + "socket,path={},server=on,wait=off,id=qmp0", + qmp_socket.to_string_lossy() + ), + "-mon", + "chardev=qmp0,mode=control", + ]); + qemu_cmd // Disk .args([ @@ -97,8 +114,6 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { // 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([ @@ -110,8 +125,9 @@ pub async fn launch_qemu(params: QemuLaunchParams) -> anyhow::Result<()> { ]) .args(["-serial", "chardev:char0"]) .args(["-mon", "chardev=char0,mode=readline"]); + + // Seed ISO is only used for initial boot with cloud-init. if params.is_init { - // Seed ISO qemu_cmd.args([ "-drive", &format!( @@ -153,7 +169,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!("[qemu] {}", strip_ansi_codes(&line)); + trace!("{}", strip_ansi_codes(&line)); } }); @@ -163,7 +179,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!("[qemu] {}", strip_ansi_codes(&line)); + error!("{}", strip_ansi_codes(&line)); } }); diff --git a/src/qmp.rs b/src/qmp.rs new file mode 100644 index 0000000..49804b6 --- /dev/null +++ b/src/qmp.rs @@ -0,0 +1,67 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use qapi::qmp::{RunState, query_status, system_powerdown}; +use tracing::debug; + +use crate::utils::QleanDirs; + +async fn with_qmp_client(socket_path: &Path, f: F) -> Result +where + F: FnOnce( + qapi::futures::QapiService< + qapi::futures::QmpStreamTokio>, + >, + ) -> Fut, + Fut: std::future::Future>, +{ + let negotiation = qapi::futures::QmpStreamTokio::open_uds(socket_path) + .await + .with_context(|| { + format!( + "failed to connect to QMP socket at {}", + socket_path.display() + ) + })?; + + let stream = negotiation + .negotiate() + .await + .context("QMP capability negotiation failed")?; + + let (service, handle) = stream.spawn_tokio(); + let result = f(service).await; + drop(handle); + result +} + +/// Path to the QEMU QMP Unix socket for a VM run directory. +pub(crate) fn qmp_socket_path(vmid: &str) -> Result { + let dirs = QleanDirs::new()?; + Ok(dirs.runs.join(vmid).join("qmp.sock")) +} + +/// Query whether the VM is running according to QMP `query-status`. +pub(crate) async fn query_running(socket_path: &Path) -> Result { + with_qmp_client(socket_path, |service| async move { + let status = service + .execute(query_status {}) + .await + .context("query-status failed")?; + Ok(status.running && matches!(status.status, RunState::running)) + }) + .await +} + +/// Request guest ACPI shutdown via QMP `system_powerdown`. +pub(crate) async fn powerdown(socket_path: &Path) -> Result<()> { + with_qmp_client(socket_path, |service| async move { + service + .execute(system_powerdown {}) + .await + .context("system-powerdown failed")?; + debug!("QMP system-powerdown accepted at {}", socket_path.display()); + Ok(()) + }) + .await +} diff --git a/src/ssh.rs b/src/ssh.rs index 1cd7b9e..94df62c 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -27,7 +27,7 @@ const ERR_EACCES: i32 = 13; // Permission denied const ERR_ENODEV: i32 = 19; // No such device #[derive(Clone, Debug)] -pub struct PersistedSshKeypair { +pub(crate) struct PersistedSshKeypair { pub pubkey_str: String, pub _pubkey_path: PathBuf, pub privkey_str: String, @@ -36,7 +36,7 @@ pub struct PersistedSshKeypair { impl PersistedSshKeypair { // Try to load a keypair from `dir` - pub fn from_dir(dir: &Path) -> Result { + pub(crate) fn from_dir(dir: &Path) -> Result { let privkey_path = dir.join("id_ed25519"); let pubkey_path = privkey_path.with_extension("pub"); let privkey_str = std::fs::read_to_string(&privkey_path)?; @@ -51,7 +51,7 @@ impl PersistedSshKeypair { } } -pub fn get_ssh_key(dir: &Path) -> Result { +pub(crate) fn get_ssh_key(dir: &Path) -> Result { // First try reading an existing keypair from disk. // If that fails we'll just create a new one. if let Ok(existing_keypair) = PersistedSshKeypair::from_dir(dir) { @@ -105,7 +105,7 @@ impl russh::client::Handler for SshClient { /// This struct is a convenience wrapper around a russh client that handles the input/output event /// loop -pub struct Session { +pub(crate) struct Session { session: russh::client::Handle, // Cached SFTP session for reuse; lazily initialized sftp: Option, @@ -255,7 +255,7 @@ impl Session { } /// Get a cached SFTP session, opening one if needed. - pub async fn get_sftp(&mut self) -> Result<&mut SftpSession> { + pub(crate) async fn get_sftp(&mut self) -> Result<&mut SftpSession> { if self.sftp.is_none() { let sftp = self.open_sftp().await?; self.sftp = Some(sftp); @@ -264,7 +264,7 @@ impl Session { } /// Call a command via SSH, streaming its output to stdout/stderr. - pub async fn call( + pub(crate) async fn call( &mut self, // env: HashMap, command: &str, @@ -322,7 +322,7 @@ impl Session { } /// Call a command via SSH and capture its output. - pub async fn call_with_output( + pub(crate) async fn call_with_output( &mut self, command: &str, cancel_token: CancellationToken, @@ -369,7 +369,7 @@ impl Session { Ok((code, stdout, stderr)) } - pub async fn close(&mut self) -> Result<()> { + pub(crate) async fn close(&mut self) -> Result<()> { self.session .disconnect(Disconnect::ByApplication, "", "English") .await?; @@ -378,7 +378,7 @@ impl Session { } /// Connect SSH and run a command that checks whether the system is ready for operation. -pub async fn connect_ssh( +pub(crate) async fn connect_ssh( cid: u32, timeout: Duration, keypair: PersistedSshKeypair, @@ -441,7 +441,7 @@ pub async fn connect_ssh( impl Session { /// Recursively create a directory and all of its parent components if they are missing. - pub async fn create_dir_all>(&mut self, path: P) -> Result<()> { + pub(crate) async fn create_dir_all>(&mut self, path: P) -> Result<()> { let path = path.as_ref(); // Build path incrementally like mkdir -p let mut cur = PathBuf::new(); @@ -476,7 +476,7 @@ impl Session { } /// Upload a single file via SFTP. - pub async fn upload_file, Q: AsRef>( + pub(crate) async fn upload_file, Q: AsRef>( &mut self, local: P, remote: Q, @@ -512,7 +512,7 @@ impl Session { } /// Download a single file via SFTP. - pub async fn download_file, Q: AsRef>( + pub(crate) async fn download_file, Q: AsRef>( &mut self, remote: P, local: Q, @@ -543,7 +543,7 @@ impl Session { /// Walk a remote directory tree over SFTP, similar to walkdir. /// Returns a depth-first list of entries including the root. - pub async fn walk_remote_dir>( + pub(crate) async fn walk_remote_dir>( &mut self, root: P, follow_links: bool, @@ -638,7 +638,7 @@ impl Session { } /// Get the primary IP address of the remote machine. - pub async fn get_remote_ip(&mut self) -> Result { + pub(crate) async fn get_remote_ip(&mut self) -> Result { let (code, stdout, _stderr) = self .call_with_output("hostname -I | awk '{print $1}'", CancellationToken::new()) .await?; @@ -651,7 +651,7 @@ impl Session { } #[derive(Clone, Debug)] -pub struct RemoteFileType { +pub(crate) struct RemoteFileType { is_dir: bool, is_file: bool, is_symlink: bool, @@ -665,19 +665,19 @@ impl RemoteFileType { is_symlink: attrs.file_type().is_symlink(), } } - pub fn is_dir(&self) -> bool { + pub(crate) fn is_dir(&self) -> bool { self.is_dir } - pub fn is_file(&self) -> bool { + pub(crate) fn is_file(&self) -> bool { self.is_file } - pub fn is_symlink(&self) -> bool { + pub(crate) fn is_symlink(&self) -> bool { self.is_symlink } } #[derive(Clone, Debug)] -pub struct RemoteDirEntry { +pub(crate) struct RemoteDirEntry { path: PathBuf, file_type: RemoteFileType, } @@ -686,11 +686,11 @@ impl RemoteDirEntry { fn new(path: PathBuf, file_type: RemoteFileType) -> Self { Self { path, file_type } } - pub fn path(&self) -> &Path { + pub(crate) fn path(&self) -> &Path { &self.path } - pub fn file_type(&self) -> &RemoteFileType { + pub(crate) fn file_type(&self) -> &RemoteFileType { &self.file_type } } diff --git a/src/utils.rs b/src/utils.rs index d2825bd..90ef1e2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -11,18 +11,18 @@ use rand::Rng; use tracing::{debug, trace}; use walkdir::WalkDir; -pub static HEX_ALPHABET: [char; 16] = [ +pub(crate) static HEX_ALPHABET: [char; 16] = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', ]; -pub const VIRSH_CONNECTION_URI: &str = "qemu:///system"; -pub const QLEAN_BRIDGE_NAME: &str = "qlbr0"; +pub(crate) const VIRSH_CONNECTION_URI: &str = "qemu:///system"; +pub(crate) 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(crate) struct QleanDirs { pub base: PathBuf, pub images: PathBuf, pub secrets: PathBuf, @@ -30,7 +30,7 @@ pub struct QleanDirs { } impl QleanDirs { - pub fn new() -> Result { + pub(crate) fn new() -> Result { let project_dir = ProjectDirs::from("", "", "qlean").expect("Couldn't get project dir"); // Dir containing persistent data (usually ~/.local/share/qlean/) @@ -57,7 +57,7 @@ impl QleanDirs { } } -pub fn create_dir(purpose: &str, path: &Path) -> Result<()> { +pub(crate) fn create_dir(purpose: &str, path: &Path) -> Result<()> { if !path.exists() { debug!("{purpose} dir {path:?} doesn't exist yet, creating"); std::fs::create_dir_all(path).expect("Failed to create directory"); @@ -65,7 +65,7 @@ pub fn create_dir(purpose: &str, path: &Path) -> Result<()> { Ok(()) } -pub fn get_free_cid(runs_dir: &Path, run_dir: &Path) -> Result { +pub(crate) fn get_free_cid(runs_dir: &Path, run_dir: &Path) -> Result { let mut cids = vec![]; let runs_dir = runs_dir.to_owned(); @@ -130,7 +130,7 @@ impl CommandExt for tokio::process::Command { } /// Ensure host prerequisites for running virtual machines. -pub async fn ensure_prerequisites() -> Result<()> { +pub(crate) async fn ensure_prerequisites() -> Result<()> { check_command_available("qemu-system-x86_64").await?; check_command_available("qemu-img").await?; check_command_available("xorriso").await?; @@ -149,7 +149,7 @@ async fn check_command_available(cmd: &str) -> Result<()> { Ok(()) } -pub fn ensure_vsock() -> Result<()> { +fn ensure_vsock() -> Result<()> { if !Path::new("/dev/vhost-vsock").exists() { bail!( "`/dev/vhost-vsock` is missing. Qlean requires vhost-vsock for SSH. Please ensure it is available and try again." @@ -246,7 +246,7 @@ async fn ensure_network() -> Result<()> { Ok(()) } -pub fn gen_random_mac() -> String { +pub(crate) fn gen_random_mac() -> String { let mut rng = rand::rng(); let bytes: [u8; 6] = [0x52, 0x54, 0x00, rng.random(), rng.random(), rng.random()]; format!( @@ -255,6 +255,6 @@ pub fn gen_random_mac() -> String { ) } -pub fn qlean_user_agent() -> &'static str { +pub(crate) fn qlean_user_agent() -> &'static str { "qlean/0.3.0" } diff --git a/tests/machine_pool.rs b/tests/machine_pool.rs index 4567244..7f713a6 100644 --- a/tests/machine_pool.rs +++ b/tests/machine_pool.rs @@ -15,8 +15,8 @@ async fn test_ping() -> Result<()> { let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); - pool.add("alice".to_string(), &image, &config).await?; - pool.add("bob".to_string(), &image, &config).await?; + pool.add("alice", &image, &config).await?; + pool.add("bob", &image, &config).await?; pool.init_all().await?; let mut alice = pool.get("alice").await.expect("Alice machine not found"); @@ -47,10 +47,10 @@ async fn test_concurrency() -> Result<()> { let image = Image::new(ImageConfig::default().with_distro(Distro::Debian)).await?; let config = MachineConfig::default(); - pool.add("vm1".to_string(), &image, &config).await?; - pool.add("vm2".to_string(), &image, &config).await?; - pool.add("vm3".to_string(), &image, &config).await?; - pool.add("vm4".to_string(), &image, &config).await?; + pool.add("vm1", &image, &config).await?; + pool.add("vm2", &image, &config).await?; + pool.add("vm3", &image, &config).await?; + pool.add("vm4", &image, &config).await?; pool.init_all().await?; pool.shutdown_all().await?; diff --git a/tests/utils.rs b/tests/utils.rs index 9afa28a..64112d7 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,9 +1,9 @@ use std::sync::Once; use tracing_indicatif::IndicatifLayer; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::{EnvFilter, fmt::time::LocalTime}; +use tracing_subscriber::{ + EnvFilter, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt, +}; /// Initialize a global tracing subscriber for integration tests. ///