Browse Source

feat: use TOML to configure creating a new domain

rust
Giovanni Torres 1 year ago
parent
commit
fbd3a717f6
  1. 1
      .gitignore
  2. 2194
      Cargo.lock
  3. 9
      Cargo.toml
  4. 167
      src/cloudinit.rs
  5. 170
      src/config.rs
  6. 2
      src/lib.rs
  7. 172
      src/vm.rs
  8. 91
      tests/cloud_init_tests.rs
  9. 118
      tests/config_tests.rs
  10. 125
      tests/image_manager_tests.rs
  11. 120
      tests/integration_test.rs
  12. 0
      tests/list_vms_tests.rs
  13. 185
      tests/vm_tests2.rs

1
.gitignore vendored

@ -1 +1,2 @@
/target
.vscode/

2194
Cargo.lock generated

File diff suppressed because it is too large Load Diff

9
Cargo.toml

@ -10,9 +10,18 @@ repository = "https://github.com/giovtorres/kvm-install-vm"
[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
dirs = "6.0"
env_logger = "0.11"
futures-util = "0.3"
indicatif = "0.17"
log = "0.4"
reqwest = { version = "0.12", features = ["stream"] }
serde = { version = "1.0", features = ["derive"] }
tempfile = "3.19"
toml = "0.8"
virt = "0.4"
tokio = { version = "1.44", features = ["full"] }
tokio-fs = "0.1"
[build-dependencies]
pkg-config = "0.3"

167
src/cloudinit.rs

@ -0,0 +1,167 @@
// cloudinit.rs - New file
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::fs;
use std::process::Command;
/// Represents a cloud-init configuration manager
pub struct CloudInitManager;
impl CloudInitManager {
/// Create cloud-init user-data and meta-data files
pub fn create_cloud_init_config(
vm_name: &str,
dns_domain: &str,
ssh_public_key: &str,
user: &str,
timezone: &str,
sudo_group: &str,
cloud_init_disable: &str,
) -> Result<(String, String)> {
// Create meta-data content
let meta_data = format!(
"instance-id: {}\nlocal-hostname: {}\n",
vm_name, vm_name
);
// Create user-data content with cloud-init multipart format
let user_data = format!(
r#"Content-Type: multipart/mixed; boundary="==BOUNDARY=="
MIME-Version: 1.0
--==BOUNDARY==
Content-Type: text/cloud-config; charset="us-ascii"
#cloud-config
# Hostname management
preserve_hostname: False
hostname: {hostname}
fqdn: {hostname}.{dns_domain}
# Users
users:
- default
- name: {user}
groups: ['{sudo_group}']
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh-authorized-keys:
- {ssh_key}
# Configure where output will go
output:
all: ">> /var/log/cloud-init.log"
# configure interaction with ssh server
ssh_genkeytypes: ['ed25519', 'rsa']
# Install my public ssh key to the user
ssh_authorized_keys:
- {ssh_key}
timezone: {timezone}
# Remove cloud-init when finished with it
runcmd:
- {cloud_init_disable}
--==BOUNDARY==--
"#,
hostname = vm_name,
dns_domain = dns_domain,
user = user,
ssh_key = ssh_public_key,
sudo_group = sudo_group,
timezone = timezone,
cloud_init_disable = cloud_init_disable
);
Ok((user_data, meta_data))
}
/// Create a cloud-init ISO from user-data and meta-data
pub fn create_cloud_init_iso(
work_dir: &Path,
vm_name: &str,
user_data: &str,
meta_data: &str
) -> Result<PathBuf> {
let user_data_path = work_dir.join("user-data");
let meta_data_path = work_dir.join("meta-data");
let iso_path = work_dir.join(format!("{}-cidata.iso", vm_name));
// Make sure the directory exists
fs::create_dir_all(work_dir)
.context("Failed to create working directory")?;
// Write files
fs::write(&user_data_path, user_data)
.context("Failed to write user-data file")?;
fs::write(&meta_data_path, meta_data)
.context("Failed to write meta-data file")?;
// Check for genisoimage or mkisofs
let mut cmd;
if Command::new("genisoimage").arg("--version").output().is_ok() {
cmd = Command::new("genisoimage");
cmd.args(&[
"-output", iso_path.to_str().unwrap(),
"-volid", "cidata",
"-joliet", "-rock",
user_data_path.to_str().unwrap(),
meta_data_path.to_str().unwrap(),
]);
} else if Command::new("mkisofs").arg("--version").output().is_ok() {
cmd = Command::new("mkisofs");
cmd.args(&[
"-o", iso_path.to_str().unwrap(),
"-V", "cidata",
"-J", "-r",
user_data_path.to_str().unwrap(),
meta_data_path.to_str().unwrap(),
]);
} else {
return Err(anyhow::anyhow!("Neither genisoimage nor mkisofs found. Please install one of these tools."));
}
// Run the command
let status = cmd.status()
.context("Failed to execute ISO creation command")?;
if !status.success() {
return Err(anyhow::anyhow!("Failed to create cloud-init ISO"));
}
// Remove temporary files
fs::remove_file(user_data_path)
.context("Failed to clean up user-data file")?;
fs::remove_file(meta_data_path)
.context("Failed to clean up meta-data file")?;
Ok(iso_path)
}
/// Find an SSH public key
pub fn find_ssh_public_key() -> Result<String> {
// Try to find a suitable key file
let possible_keys = [
"id_rsa.pub",
"id_ed25519.pub",
"id_dsa.pub",
];
if let Some(home) = dirs::home_dir() {
let ssh_dir = home.join(".ssh");
for key_name in possible_keys.iter() {
let key_path = ssh_dir.join(key_name);
if key_path.exists() {
return fs::read_to_string(&key_path)
.context(format!("Failed to read SSH key from {}", key_path.display()));
}
}
}
Err(anyhow::anyhow!("No SSH public key found. Please generate an SSH keypair using 'ssh-keygen' or specify one with the '-k' flag."))
}
}

170
src/config.rs

@ -0,0 +1,170 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::fs;
use anyhow::{Context, Result};
use crate::vm::DistroInfo;
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub distros: HashMap<String, DistroInfo>,
#[serde(default)]
pub defaults: DefaultConfig,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DefaultConfig {
pub memory_mb: u32,
pub vcpus: u32,
pub disk_size_gb: u32,
pub image_dir: String,
pub vm_dir: String,
pub dns_domain: String,
pub timezone: String,
}
impl Default for DefaultConfig {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
DefaultConfig {
memory_mb: 1024,
vcpus: 1,
disk_size_gb: 10,
image_dir: home.join("virt/images").to_string_lossy().to_string(),
vm_dir: home.join("virt/vms").to_string_lossy().to_string(),
dns_domain: "example.local".to_string(),
timezone: "UTC".to_string(),
}
}
}
impl Config {
/// Load configuration from a specified path
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(&path)
.context(format!("Failed to read config file: {}", path.as_ref().display()))?;
// Changed from serde_yaml to toml
let config: Config = toml::from_str(&content)
.context("Failed to parse TOML configuration")?;
Ok(config)
}
/// Load configuration from the default locations
pub fn load() -> Result<Self> {
// Check for config in user's config directory
if let Some(config_dir) = dirs::config_dir() {
// Changed file extension from yaml to toml
let user_config = config_dir.join("kvm-install-vm/config.toml");
if user_config.exists() {
return Self::from_file(user_config);
}
}
// Check for config in user's home directory
if let Some(home_dir) = dirs::home_dir() {
// Changed file extension from yaml to toml
let home_config = home_dir.join(".config/kvm-install-vm/config.toml");
if home_config.exists() {
return Self::from_file(home_config);
}
// Legacy location
let old_config = home_dir.join(".kivrc");
if old_config.exists() {
// TODO: Convert legacy config format if needed
println!("Legacy .kivrc file found but not supported. Please convert to TOML format.");
}
}
// Check for system-wide config
// Changed file extension from yaml to toml
let system_config = Path::new("/etc/kvm-install-vm/config.toml");
if system_config.exists() {
return Self::from_file(system_config);
}
// If no config files found, return default config
Ok(Self::default())
}
/// Save configuration to a file
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
// Changed from serde_yaml to toml
let toml_content = toml::to_string_pretty(self)
.context("Failed to serialize configuration to TOML")?;
// Ensure parent directory exists
if let Some(parent) = path.as_ref().parent() {
fs::create_dir_all(parent)
.context(format!("Failed to create directory: {}", parent.display()))?;
}
fs::write(&path, toml_content)
.context(format!("Failed to write config to: {}", path.as_ref().display()))?;
Ok(())
}
/// Save configuration to the default user location
pub fn save_to_user_config(&self) -> Result<()> {
if let Some(config_dir) = dirs::config_dir() {
// Changed file extension from yaml to toml
let user_config = config_dir.join("kvm-install-vm/config.toml");
self.save_to_file(user_config)
} else {
Err(anyhow::anyhow!("Could not determine user config directory"))
}
}
/// Get distribution info by name
pub fn get_distro(&self, name: &str) -> Result<&DistroInfo> {
self.distros.get(name)
.ok_or_else(|| anyhow::anyhow!("Distribution '{}' not found in configuration", name))
}
}
impl Default for Config {
fn default() -> Self {
// Create a default configuration with some common distributions
let mut distros = HashMap::new();
// CentOS 8
distros.insert("centos8".to_string(), DistroInfo {
qcow_filename: "CentOS-8-GenericCloud-8.1.1911-20200113.3.x86_64.qcow2".to_string(),
os_variant: "centos8".to_string(),
image_url: "https://cloud.centos.org/centos/8/x86_64/images".to_string(),
login_user: "centos".to_string(),
sudo_group: "wheel".to_string(),
cloud_init_disable: "systemctl disable cloud-init.service".to_string(),
});
// Ubuntu 20.04
distros.insert("ubuntu2004".to_string(), DistroInfo {
qcow_filename: "ubuntu-20.04-server-cloudimg-amd64.img".to_string(),
os_variant: "ubuntu20.04".to_string(),
image_url: "https://cloud-images.ubuntu.com/releases/20.04/release".to_string(),
login_user: "ubuntu".to_string(),
sudo_group: "sudo".to_string(),
cloud_init_disable: "systemctl disable cloud-init.service".to_string(),
});
// Fedora 35
distros.insert("fedora35".to_string(), DistroInfo {
qcow_filename: "Fedora-Cloud-Base-35-1.2.x86_64.qcow2".to_string(),
os_variant: "fedora35".to_string(),
image_url: "https://download.fedoraproject.org/pub/fedora/linux/releases/35/Cloud/x86_64/images".to_string(),
login_user: "fedora".to_string(),
sudo_group: "wheel".to_string(),
cloud_init_disable: "systemctl disable cloud-init.service".to_string(),
});
Config {
distros,
defaults: DefaultConfig::default(),
}
}
}

2
src/lib.rs

@ -1,4 +1,6 @@
pub mod cli;
pub mod vm;
pub mod config;
pub mod cloudinit;
pub use cli::Cli;

172
src/vm.rs

@ -1,6 +1,14 @@
use anyhow::{Context, Result};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use virt::connect::Connect;
use virt::domain::Domain;
use virt::sys;
@ -15,6 +23,20 @@ pub struct VirtualMachine {
pub connection: Option<Connect>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DistroInfo {
pub qcow_filename: String,
pub os_variant: String,
pub image_url: String,
pub login_user: String,
pub sudo_group: String,
pub cloud_init_disable: String,
}
pub struct ImageManager {
image_dir: PathBuf,
}
#[derive(Debug)]
pub struct DomainInfo {
pub id: Option<u32>, // None if domain is inactive
@ -46,6 +68,154 @@ impl fmt::Display for DomainState {
}
}
impl ImageManager {
/// Create a new ImageManager with the specified image directory
pub fn new<P: AsRef<Path>>(image_dir: P) -> Self {
ImageManager {
image_dir: image_dir.as_ref().to_path_buf(),
}
}
/// Check if a cloud image exists locally
pub fn image_exists(&self, distro_info: &DistroInfo) -> bool {
let image_path = self.image_dir.join(&distro_info.qcow_filename);
image_path.exists()
}
/// Get the full path to a cloud image (whether it exists or not)
pub fn get_image_path(&self, distro_info: &DistroInfo) -> PathBuf {
self.image_dir.join(&distro_info.qcow_filename)
}
/// Download a cloud image if it doesn't already exist locally
pub async fn ensure_image(&self, distro_info: &DistroInfo) -> Result<PathBuf> {
let image_path = self.get_image_path(distro_info);
if image_path.exists() {
println!("Cloud image already exists: {}", image_path.display());
return Ok(image_path);
}
// Create image directory if it doesn't exist
if !self.image_dir.exists() {
fs::create_dir_all(&self.image_dir)
.context("Failed to create image directory")?;
}
println!("Downloading cloud image: {}", distro_info.qcow_filename);
// Construct download URL
let url = format!("{}/{}",
distro_info.image_url.trim_end_matches('/'),
distro_info.qcow_filename);
println!("From URL: {}", url);
// Download the file with progress indication
self.download_file(&url, &image_path).await
.context("Failed to download cloud image")?;
Ok(image_path)
}
/// Download a file with progress indication
async fn download_file(&self, url: &str, dest: &Path) -> Result<()> {
// Create a temporary file for downloading
let temp_path = dest.with_extension("part");
// Create parent directory if needed
if let Some(parent) = temp_path.parent() {
fs::create_dir_all(parent)?;
}
// Begin the download
let res = reqwest::get(url).await?;
let total_size = res.content_length().unwrap_or(0);
// Setup progress bar
let pb = ProgressBar::new(total_size);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"));
// Download the file in chunks, writing each chunk to disk
let mut file = File::create(&temp_path).await?;
let mut downloaded: u64 = 0;
let mut stream = res.bytes_stream();
while let Some(item) = stream.next().await {
let chunk = item?;
file.write_all(&chunk).await?;
downloaded += chunk.len() as u64;
pb.set_position(downloaded);
}
// Ensure everything is written to disk
file.flush().await?;
// Finalize the download by renaming the temp file
tokio::fs::rename(&temp_path, &dest).await?;
pb.finish_with_message(format!("Downloaded {}", dest.display()));
Ok(())
}
/// Prepare a VM disk from a cloud image
pub fn prepare_vm_disk(
&self,
base_image: &Path,
vm_dir: &Path,
vm_name: &str,
disk_size_gb: u32
) -> Result<PathBuf> {
let disk_path = vm_dir.join(format!("{}.qcow2", vm_name));
// Create VM directory if it doesn't exist
fs::create_dir_all(vm_dir)
.context("Failed to create VM directory")?;
// Create base disk by copying from the cloud image
println!("Creating disk from cloud image: {}", disk_path.display());
let status = Command::new("qemu-img")
.args(&[
"create",
"-f", "qcow2",
"-F", "qcow2",
"-b", &base_image.to_string_lossy(),
&disk_path.to_string_lossy(),
])
.status()
.context("Failed to execute qemu-img create command")?;
if !status.success() {
return Err(anyhow::anyhow!("qemu-img create failed with status: {}", status));
}
// Resize disk if requested
if disk_size_gb > 0 {
println!("Resizing disk to {}GB", disk_size_gb);
let status = Command::new("qemu-img")
.args(&[
"resize",
&disk_path.to_string_lossy(),
&format!("{}G", disk_size_gb),
])
.status()
.context("Failed to execute qemu-img resize command")?;
if !status.success() {
return Err(anyhow::anyhow!("qemu-img resize failed with status: {}", status));
}
}
Ok(disk_path)
}
}
impl VirtualMachine {
pub fn new(
name: String,

91
tests/cloud_init_tests.rs

@ -0,0 +1,91 @@
// tests/cloud_init_tests.rs
use anyhow::Result;
use kvm_install_vm::cloudinit::CloudInitManager;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_create_cloud_init_config() -> Result<()> {
let (user_data, meta_data) = CloudInitManager::create_cloud_init_config(
"test-vm",
"example.local",
"ssh-rsa AAAAB3NzaC1yc2E... test-key",
"testuser",
"UTC",
"wheel",
"systemctl disable cloud-init"
)?;
// Verify user_data contains expected elements
assert!(user_data.contains("hostname: test-vm"));
assert!(user_data.contains("fqdn: test-vm.example.local"));
assert!(user_data.contains("name: testuser"));
assert!(user_data.contains("groups: ['wheel']"));
assert!(user_data.contains("ssh-rsa AAAAB3NzaC1yc2E... test-key"));
assert!(user_data.contains("timezone: UTC"));
// Verify meta_data contains expected elements
assert!(meta_data.contains("instance-id: test-vm"));
assert!(meta_data.contains("local-hostname: test-vm"));
Ok(())
}
#[test]
fn test_create_cloud_init_iso() -> Result<()> {
// Skip if neither genisoimage nor mkisofs is available
let has_genisoimage = std::process::Command::new("genisoimage")
.arg("--version")
.output()
.is_ok();
let has_mkisofs = std::process::Command::new("mkisofs")
.arg("--version")
.output()
.is_ok();
if !has_genisoimage && !has_mkisofs {
println!("Skipping test_create_cloud_init_iso: neither genisoimage nor mkisofs available");
return Ok(());
}
let temp_dir = tempdir()?;
// Create test cloud-init data
let user_data = "#cloud-config\nhostname: test-vm\n";
let meta_data = "instance-id: test-vm\nlocal-hostname: test-vm\n";
// Create ISO
let iso_path = CloudInitManager::create_cloud_init_iso(
temp_dir.path(),
"test-vm",
user_data,
meta_data
)?;
// Verify ISO was created
assert!(iso_path.exists());
assert!(fs::metadata(&iso_path)?.len() > 0);
// Verify the temporary files were cleaned up
assert!(!temp_dir.path().join("user-data").exists());
assert!(!temp_dir.path().join("meta-data").exists());
Ok(())
}
#[test]
fn test_find_ssh_public_key() {
// This test is tricky because it depends on the local environment
// Just check that the function either succeeds or fails with a reasonable error
match CloudInitManager::find_ssh_public_key() {
Ok(key) => {
assert!(!key.is_empty());
assert!(key.starts_with("ssh-"));
},
Err(e) => {
// The error should mention SSH keys
assert!(format!("{}", e).contains("SSH"));
}
}
}

118
tests/config_tests.rs

@ -0,0 +1,118 @@
// tests/config_tests.rs
use anyhow::Result;
use kvm_install_vm::config::{Config, DefaultConfig};
use std::collections::HashMap;
use tempfile::tempdir;
#[test]
fn test_config_defaults() {
let config = Config::default();
// Check that default distributions exist
assert!(config.distros.contains_key("centos8"));
assert!(config.distros.contains_key("ubuntu2004"));
// Check default settings
assert_eq!(config.defaults.memory_mb, 1024);
assert_eq!(config.defaults.vcpus, 1);
assert_eq!(config.defaults.disk_size_gb, 10);
}
#[test]
fn test_config_serialization_deserialization() -> Result<()> {
// Create a temporary directory for the test
let temp_dir = tempdir()?;
let config_path = temp_dir.path().join("test-config.toml");
// Create a test configuration
let mut test_distros = HashMap::new();
test_distros.insert("test-distro".to_string(), kvm_install_vm::vm::DistroInfo {
qcow_filename: "test-image.qcow2".to_string(),
os_variant: "test-os".to_string(),
image_url: "https://example.com/images".to_string(),
login_user: "testuser".to_string(),
sudo_group: "wheel".to_string(),
cloud_init_disable: "systemctl disable cloud-init".to_string(),
});
let test_defaults = DefaultConfig {
memory_mb: 2048,
vcpus: 2,
disk_size_gb: 20,
image_dir: "/tmp/test-images".to_string(),
vm_dir: "/tmp/test-vms".to_string(),
dns_domain: "test.local".to_string(),
timezone: "UTC".to_string(),
};
let original_config = Config {
distros: test_distros,
defaults: test_defaults,
};
// Save and then reload the configuration
original_config.save_to_file(&config_path)?;
let loaded_config = Config::from_file(&config_path)?;
// Verify the loaded configuration matches the original
assert_eq!(loaded_config.distros.len(), original_config.distros.len());
assert!(loaded_config.distros.contains_key("test-distro"));
assert_eq!(
loaded_config.distros["test-distro"].qcow_filename,
"test-image.qcow2"
);
assert_eq!(loaded_config.defaults.memory_mb, 2048);
assert_eq!(loaded_config.defaults.vcpus, 2);
Ok(())
}
#[test]
fn test_config_from_toml_string() -> Result<()> {
// Define a test TOML configuration
let toml_str = r#"
[distros.test-distro]
qcow_filename = "test-image.qcow2"
os_variant = "test-os"
image_url = "https://example.com/images"
login_user = "testuser"
sudo_group = "wheel"
cloud_init_disable = "systemctl disable cloud-init"
[defaults]
memory_mb = 4096
vcpus = 4
disk_size_gb = 40
image_dir = "/custom/images"
vm_dir = "/custom/vms"
dns_domain = "custom.local"
timezone = "America/New_York"
"#;
// Parse the TOML string
let config: Config = toml::from_str(toml_str)?;
// Verify the configuration
assert_eq!(config.distros.len(), 1);
assert!(config.distros.contains_key("test-distro"));
assert_eq!(config.defaults.memory_mb, 4096);
assert_eq!(config.defaults.vcpus, 4);
assert_eq!(config.defaults.timezone, "America/New_York");
Ok(())
}
#[test]
fn test_get_distro() -> Result<()> {
let config = Config::default();
// Test existing distro
let centos = config.get_distro("centos8")?;
assert_eq!(centos.os_variant, "centos8");
// Test non-existent distro
let result = config.get_distro("nonexistent-distro");
assert!(result.is_err());
Ok(())
}

125
tests/image_manager_tests.rs

@ -0,0 +1,125 @@
// tests/image_manager_tests.rs - Fixed version
use anyhow::Result;
use kvm_install_vm::vm::{DistroInfo, ImageManager};
use std::fs;
use tempfile::tempdir;
use tokio::runtime::Runtime;
// Helper function to create a test distro info
fn create_test_distro() -> DistroInfo {
DistroInfo {
qcow_filename: "test-image.qcow2".to_string(),
os_variant: "test-os".to_string(),
image_url: "https://example.com/images".to_string(),
login_user: "testuser".to_string(),
sudo_group: "wheel".to_string(),
cloud_init_disable: "systemctl disable cloud-init".to_string(),
}
}
#[test]
fn test_image_manager_initialization() {
let temp_dir = tempdir().unwrap();
let image_manager = ImageManager::new(temp_dir.path());
// Test that the image directory is set correctly
assert_eq!(
image_manager.get_image_path(&create_test_distro()),
temp_dir.path().join("test-image.qcow2")
);
}
#[test]
fn test_image_existence_check() -> Result<()> {
let temp_dir = tempdir()?;
let image_manager = ImageManager::new(temp_dir.path());
let distro = create_test_distro();
// Initially, the image should not exist
assert!(!image_manager.image_exists(&distro));
// Create an empty file at the image path
let image_path = temp_dir.path().join("test-image.qcow2");
fs::write(&image_path, b"test content")?;
// Now the image should be reported as existing
assert!(image_manager.image_exists(&distro));
Ok(())
}
// This test is marked as ignored because it would attempt to download
// a real cloud image, which we don't want in automated testing
#[test]
#[ignore]
fn test_ensure_image() -> Result<()> {
let rt = Runtime::new()?;
let temp_dir = tempdir()?;
let image_manager = ImageManager::new(temp_dir.path());
// This distro has a real URL that would be downloaded
let distro = DistroInfo {
qcow_filename: "cirros-0.5.1-x86_64-disk.img".to_string(),
os_variant: "cirros".to_string(),
image_url: "http://download.cirros-cloud.net/0.5.1".to_string(),
login_user: "cirros".to_string(),
sudo_group: "wheel".to_string(),
cloud_init_disable: "systemctl disable cloud-init".to_string(),
};
// Use the tokio runtime to run the async function
let image_path = rt.block_on(image_manager.ensure_image(&distro))?;
// Verify the image was downloaded
assert!(image_path.exists());
assert!(fs::metadata(&image_path)?.len() > 0);
Ok(())
}
#[test]
fn test_prepare_vm_disk() -> Result<()> {
// Skip if qemu-img is not available
if std::process::Command::new("qemu-img").arg("--version").output().is_err() {
println!("Skipping test_prepare_vm_disk: qemu-img not available");
return Ok(());
}
let temp_dir = tempdir()?;
let image_manager = ImageManager::new(temp_dir.path());
// Create a dummy base image
let base_image = temp_dir.path().join("base.qcow2");
std::process::Command::new("qemu-img")
.args(&["create", "-f", "qcow2", &base_image.to_string_lossy(), "1G"])
.output()?;
// Create VM directory
let vm_dir = temp_dir.path().join("vms/test-vm");
fs::create_dir_all(&vm_dir)?;
// Prepare VM disk
let disk_path = image_manager.prepare_vm_disk(
&base_image,
&vm_dir,
"test-vm",
5 // 5GB disk
)?;
// Verify the disk was created
assert!(disk_path.exists());
// Check disk properties with qemu-img info
let output = std::process::Command::new("qemu-img")
.args(&["info", "--output=json", &disk_path.to_string_lossy()])
.output()?;
let info_str = String::from_utf8(output.stdout)?;
assert!(info_str.contains("qcow2"));
// Fix: Convert Cow to a regular str for comparison
let base_path_str = base_image.to_string_lossy().to_string();
assert!(info_str.contains(&base_path_str));
Ok(())
}

120
tests/integration_test.rs

@ -0,0 +1,120 @@
// tests/integration_test.rs
use anyhow::Result;
use kvm_install_vm::{
vm::{DistroInfo, ImageManager, VirtualMachine},
cloudinit::CloudInitManager,
};
use std::fs;
use tempfile::tempdir;
// This test combines multiple components but doesn't actually create a VM
// It prepares everything up to the point of VM creation
#[test]
fn test_vm_preparation_flow() -> Result<()> {
// Skip if qemu-img is not available
if std::process::Command::new("qemu-img").arg("--version").output().is_err() {
println!("Skipping test_vm_preparation_flow: qemu-img not available");
return Ok(());
}
// Skip if neither genisoimage nor mkisofs is available
let has_iso_tool = std::process::Command::new("genisoimage").arg("--version").output().is_ok() ||
std::process::Command::new("mkisofs").arg("--version").output().is_ok();
if !has_iso_tool {
println!("Skipping test_vm_preparation_flow: neither genisoimage nor mkisofs available");
return Ok(());
}
// Create temporary directories
let temp_dir = tempdir()?;
let image_dir = temp_dir.path().join("images");
let vm_dir = temp_dir.path().join("vms");
fs::create_dir_all(&image_dir)?;
fs::create_dir_all(&vm_dir)?;
// Create a test distro
let distro = DistroInfo {
qcow_filename: "test-integration.qcow2".to_string(),
os_variant: "generic".to_string(),
image_url: "http://example.com".to_string(),
login_user: "testuser".to_string(),
sudo_group: "wheel".to_string(),
cloud_init_disable: "systemctl disable cloud-init".to_string(),
};
// Create a base image (since we won't download one)
let base_image = image_dir.join(&distro.qcow_filename);
std::process::Command::new("qemu-img")
.args(&["create", "-f", "qcow2", &base_image.to_string_lossy(), "1G"])
.output()?;
// Initialize image manager
let image_manager = ImageManager::new(&image_dir);
// Prepare VM directory
let vm_name = "test-integration-vm";
let vm_dir_path = vm_dir.join(vm_name);
fs::create_dir_all(&vm_dir_path)?;
// Prepare VM disk
let disk_path = image_manager.prepare_vm_disk(
&base_image,
&vm_dir_path,
vm_name,
5
)?;
// Create mock SSH key
let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7RXIKhCmT... test@example.com";
// Create cloud-init configuration
let (user_data, meta_data) = CloudInitManager::create_cloud_init_config(
vm_name,
"test.local",
ssh_key,
"testuser",
"UTC",
"wheel",
"systemctl disable cloud-init"
)?;
// Create cloud-init ISO
let iso_path = CloudInitManager::create_cloud_init_iso(
&vm_dir_path,
vm_name,
&user_data,
&meta_data
)?;
// Initialize VM (but don't create it)
let vm = VirtualMachine::new(
vm_name.to_string(),
2,
1024,
5,
disk_path.to_string_lossy().to_string(),
);
// Since we can't call generate_domain_xml() directly, we'll verify the
// components we've prepared instead
// Verify the preparation was successful
assert!(disk_path.exists());
assert!(iso_path.exists());
// We can verify the VM setup parameters
assert_eq!(vm.name, "test-integration-vm");
assert_eq!(vm.vcpus, 2);
assert_eq!(vm.memory_mb, 1024);
assert_eq!(vm.disk_size_gb, 5);
// For a more complete test that would require libvirt, we could:
// 1. Connect to libvirt
// 2. Create the VM
// 3. Inspect the actual XML via virsh
// But we'll skip that to keep this a non-libvirt test
Ok(())
}

0
tests/list_vms.rs → tests/list_vms_tests.rs

185
tests/vm_tests2.rs

@ -0,0 +1,185 @@
use anyhow::Result;
use kvm_install_vm::vm::{VirtualMachine, DomainState};
use tempfile::tempdir;
// Test VM creation and initialization without actually interacting with libvirt
#[test]
fn test_vm_initialization() {
let vm = VirtualMachine::new(
"test-vm".to_string(),
2,
1024,
10,
"/tmp/test-vm.qcow2".to_string(),
);
assert_eq!(vm.name, "test-vm");
assert_eq!(vm.vcpus, 2);
assert_eq!(vm.memory_mb, 1024);
assert_eq!(vm.disk_size_gb, 10);
assert_eq!(vm.disk_path, "/tmp/test-vm.qcow2");
assert!(vm.connection.is_none());
}
// Since generate_domain_xml is private, we'll test indirectly through create()
// This test is marked as ignored because it requires libvirt
#[test]
#[ignore]
fn test_domain_creation_xml() -> Result<()> {
// Create a temporary directory for disk images
let temp_dir = tempdir()?;
let disk_path = temp_dir.path().join("test-xml-vm.qcow2");
// Create a test disk image
std::process::Command::new("qemu-img")
.args(&[
"create",
"-f", "qcow2",
&disk_path.to_string_lossy(),
"1G"
])
.output()?;
// Create VM
let mut vm = VirtualMachine::new(
"test-xml-vm".to_string(),
2,
1024,
10,
disk_path.to_string_lossy().to_string(),
);
// Connect to libvirt
vm.connect(None)?;
// Use virsh to dump and inspect XML
// This is a workaround since we can't call generate_domain_xml directly
let output = std::process::Command::new("virsh")
.args(&[
"-c", "qemu:///session",
"dumpxml",
"test-xml-vm",
])
.output()?;
if output.status.success() {
let xml = String::from_utf8(output.stdout)?;
// Check for relevant XML elements
assert!(xml.contains("<name>test-xml-vm</name>"));
assert!(xml.contains("<memory"));
assert!(xml.contains("<vcpu>"));
assert!(xml.contains("<interface type='user'>"));
assert!(!xml.contains("<interface type='network'>"));
}
// Clean up
let _ = VirtualMachine::destroy("test-xml-vm", None, true);
Ok(())
}
// The following tests require libvirt to be running
// They're marked as ignored so they don't run in automated testing
#[test]
#[ignore]
fn test_connect_to_libvirt() -> Result<()> {
let mut vm = VirtualMachine::new(
"test-connect-vm".to_string(),
1,
512,
1,
"/tmp/test-connect-vm.qcow2".to_string(),
);
vm.connect(None)?;
assert!(vm.connection.is_some());
Ok(())
}
#[test]
#[ignore]
fn test_domain_list_and_print() -> Result<()> {
// Test the domain listing functionality
let domains = VirtualMachine::list_domains(None)?;
// Print the domains for debug purposes
println!("Found {} domains:", domains.len());
for domain in &domains {
println!(
"Domain: {}, ID: {:?}, State: {:?}",
domain.name, domain.id, domain.state
);
// State consistency checks
if domain.state == DomainState::Shutoff {
assert_eq!(domain.id, None);
}
if domain.state == DomainState::Running {
assert!(domain.id.is_some());
}
}
// Test the print function (just make sure it doesn't crash)
VirtualMachine::print_domain_list(None, true, false, false)?;
Ok(())
}
// This test is complex and potentially disruptive
// It creates and then destroys a real VM, so use with caution
#[test]
#[ignore]
fn test_create_and_destroy_vm() -> Result<()> {
// Create a temporary directory for disk images
let temp_dir = tempdir()?;
let disk_path = temp_dir.path().join("test-create-destroy.qcow2");
// Create a test disk image
std::process::Command::new("qemu-img")
.args(&[
"create",
"-f", "qcow2",
&disk_path.to_string_lossy(),
"1G"
])
.output()?;
// Create VM
let mut vm = VirtualMachine::new(
"test-create-destroy".to_string(),
1,
512,
1,
disk_path.to_string_lossy().to_string(),
);
vm.connect(None)?;
// Create the VM
let domain = vm.create()?;
// Verify domain exists
let domain_name = domain.get_name()?;
assert_eq!(domain_name, "test-create-destroy");
// Try to destroy it
VirtualMachine::destroy("test-create-destroy", None, true)?;
// Verify it's gone using virsh
let output = std::process::Command::new("virsh")
.args(&[
"-c", "qemu:///session",
"dominfo",
"test-create-destroy",
])
.output()?;
assert!(!output.status.success());
Ok(())
}
Loading…
Cancel
Save