13 changed files with 3192 additions and 162 deletions
@ -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.")) |
||||
} |
||||
} |
||||
@ -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(), |
||||
} |
||||
} |
||||
} |
||||
@ -1,4 +1,6 @@
|
||||
pub mod cli; |
||||
pub mod vm; |
||||
pub mod config; |
||||
pub mod cloudinit; |
||||
|
||||
pub use cli::Cli; |
||||
|
||||
@ -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")); |
||||
} |
||||
} |
||||
} |
||||
@ -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(()) |
||||
} |
||||
@ -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(()) |
||||
} |
||||
@ -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,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…
Reference in new issue