|
|
|
|
@ -13,9 +13,9 @@ use virt::connect::Connect;
|
|
|
|
|
use virt::domain::Domain; |
|
|
|
|
use virt::sys; |
|
|
|
|
|
|
|
|
|
use crate::cloudinit::CloudInitManager; |
|
|
|
|
use crate::config::Config; |
|
|
|
|
use crate::domain::{DomainInfo, DomainState, extract_disk_paths_from_xml}; |
|
|
|
|
use crate::cloudinit::CloudInitManager; |
|
|
|
|
|
|
|
|
|
pub struct VirtualMachine { |
|
|
|
|
pub name: String, |
|
|
|
|
@ -138,61 +138,61 @@ impl ImageManager {
|
|
|
|
|
|
|
|
|
|
Ok(dest.to_path_buf()) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Download a cloud image with resume capability
|
|
|
|
|
#[instrument(skip(self), fields(distro = %distro_info.qcow_filename))] |
|
|
|
|
pub async fn download_image_with_resume(&self, distro_info: &DistroInfo) -> Result<PathBuf> { |
|
|
|
|
let image_path = self.image_dir.join(&distro_info.qcow_filename); |
|
|
|
|
let part_path = image_path.with_extension("part"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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")?; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if the image already exists
|
|
|
|
|
if image_path.exists() { |
|
|
|
|
info!("Cloud image already exists: {}", image_path.display()); |
|
|
|
|
println!("Cloud image already exists: {}", image_path.display()); |
|
|
|
|
return Ok(image_path); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Construct download URL
|
|
|
|
|
let url = format!( |
|
|
|
|
"{}/{}", |
|
|
|
|
distro_info.image_url.trim_end_matches('/'), |
|
|
|
|
distro_info.qcow_filename |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
info!("Downloading cloud image: {}", distro_info.qcow_filename); |
|
|
|
|
println!("Downloading cloud image: {}", distro_info.qcow_filename); |
|
|
|
|
debug!("From URL: {}", url); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if partial download exists
|
|
|
|
|
let resume_download = part_path.exists(); |
|
|
|
|
if resume_download { |
|
|
|
|
info!("Partial download found. Resuming from previous download"); |
|
|
|
|
println!("Partial download found. Resuming from previous download"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let client = reqwest::Client::new(); |
|
|
|
|
let file_size = part_path.metadata()?.len(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
debug!("Resuming from byte position: {}", file_size); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create a request with Range header
|
|
|
|
|
let mut req = client.get(&url); |
|
|
|
|
req = req.header("Range", format!("bytes={}-", file_size)); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Download the rest of the file
|
|
|
|
|
let res = req.send().await?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if the server supports resume
|
|
|
|
|
if res.status() == reqwest::StatusCode::PARTIAL_CONTENT { |
|
|
|
|
let total_size = match res.content_length() { |
|
|
|
|
Some(len) => file_size + len, |
|
|
|
|
None => file_size, // Just show the current size if total is unknown
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Setup progress bar
|
|
|
|
|
let pb = ProgressBar::new(total_size); |
|
|
|
|
pb.set_style(ProgressStyle::default_bar() |
|
|
|
|
@ -200,41 +200,41 @@ impl ImageManager {
|
|
|
|
|
.unwrap() |
|
|
|
|
.progress_chars("#>-")); |
|
|
|
|
pb.set_position(file_size); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Open the existing part file for appending
|
|
|
|
|
let mut file = tokio::fs::OpenOptions::new() |
|
|
|
|
.append(true) |
|
|
|
|
.open(&part_path) |
|
|
|
|
.await?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let mut downloaded = file_size; |
|
|
|
|
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(&part_path, &image_path).await?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pb.finish_with_message(format!("Downloaded {}", image_path.display())); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return Ok(image_path); |
|
|
|
|
} else { |
|
|
|
|
warn!("Server does not support resume. Starting a new download"); |
|
|
|
|
println!("Server does not support resume. Starting a new download"); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// If we got here, we need to do a full download
|
|
|
|
|
self.download_file(&url, &image_path).await?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Ok(image_path) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -281,50 +281,70 @@ impl VirtualMachine {
|
|
|
|
|
#[instrument(skip(self, config), fields(vm_name = %self.name))] |
|
|
|
|
pub async fn prepare_image(&mut self, distro: &str, config: &Config) -> Result<()> { |
|
|
|
|
info!("Preparing image for VM: {}", self.name); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// First check if domain exists in libvirt
|
|
|
|
|
if let Ok(true) = self.domain_exists() { |
|
|
|
|
return Err(anyhow::anyhow!( |
|
|
|
|
"Domain '{}' already exists in libvirt", |
|
|
|
|
self.name |
|
|
|
|
)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if VM files exist on disk
|
|
|
|
|
if self.vm_files_exist() { |
|
|
|
|
return Err(anyhow::anyhow!( |
|
|
|
|
"VM '{}' files already exist on disk. Use destroy command with --remove-disk flag first", |
|
|
|
|
self.name |
|
|
|
|
)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Get distribution info
|
|
|
|
|
let distro_info = config.get_distro(distro)?; |
|
|
|
|
debug!("Using distro: {}", distro); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Setup image manager
|
|
|
|
|
let image_dir = PathBuf::from(&config.defaults.image_dir); |
|
|
|
|
let image_manager = ImageManager::new(&image_dir); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure we have the cloud image
|
|
|
|
|
info!("Checking for cloud image"); |
|
|
|
|
let cloud_image = image_manager.ensure_image(distro_info).await?; |
|
|
|
|
debug!("Cloud image path: {}", cloud_image.display()); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create VM directory if it doesn't exist
|
|
|
|
|
let vm_dir = PathBuf::from(&config.defaults.vm_dir).join(&self.name); |
|
|
|
|
if !vm_dir.exists() { |
|
|
|
|
fs::create_dir_all(&vm_dir).context("Failed to create VM directory")?; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create disk path for the VM
|
|
|
|
|
self.disk_path = vm_dir.join(format!("{}.qcow2", self.name)) |
|
|
|
|
self.disk_path = vm_dir |
|
|
|
|
.join(format!("{}.qcow2", self.name)) |
|
|
|
|
.to_string_lossy() |
|
|
|
|
.to_string(); |
|
|
|
|
debug!("Disk path: {}", self.disk_path); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create disk image from the cloud image
|
|
|
|
|
info!("Creating disk image for VM"); |
|
|
|
|
let mut cmd = Command::new("qemu-img"); |
|
|
|
|
cmd.args([ |
|
|
|
|
"create", |
|
|
|
|
"-f", "qcow2", |
|
|
|
|
"-F", "qcow2", |
|
|
|
|
"-b", cloud_image.to_str().unwrap(), |
|
|
|
|
"-f", |
|
|
|
|
"qcow2", |
|
|
|
|
"-F", |
|
|
|
|
"qcow2", |
|
|
|
|
"-b", |
|
|
|
|
cloud_image.to_str().unwrap(), |
|
|
|
|
&self.disk_path, |
|
|
|
|
]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
debug!("Running command: {:?}", cmd); |
|
|
|
|
let status = cmd.status().context("Failed to execute qemu-img command")?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if !status.success() { |
|
|
|
|
return Err(anyhow::anyhow!("Failed to create disk image")); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Resize disk if needed
|
|
|
|
|
if self.disk_size_gb > 10 { |
|
|
|
|
info!("Resizing disk to {}GB", self.disk_size_gb); |
|
|
|
|
@ -334,19 +354,19 @@ impl VirtualMachine {
|
|
|
|
|
&self.disk_path, |
|
|
|
|
&format!("{}G", self.disk_size_gb), |
|
|
|
|
]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
debug!("Running command: {:?}", resize_cmd); |
|
|
|
|
let resize_status = resize_cmd.status().context("Failed to resize disk")?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if !resize_status.success() { |
|
|
|
|
return Err(anyhow::anyhow!("Failed to resize disk image")); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create cloud-init configuration
|
|
|
|
|
info!("Creating cloud-init configuration"); |
|
|
|
|
let ssh_key = CloudInitManager::find_ssh_public_key()?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let (user_data, meta_data) = CloudInitManager::create_cloud_init_config( |
|
|
|
|
&self.name, |
|
|
|
|
&config.defaults.dns_domain, |
|
|
|
|
@ -356,16 +376,12 @@ impl VirtualMachine {
|
|
|
|
|
&distro_info.sudo_group, |
|
|
|
|
&distro_info.cloud_init_disable, |
|
|
|
|
)?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create cloud-init ISO
|
|
|
|
|
let iso_path = CloudInitManager::create_cloud_init_iso( |
|
|
|
|
&vm_dir, |
|
|
|
|
&self.name, |
|
|
|
|
&user_data, |
|
|
|
|
&meta_data, |
|
|
|
|
)?; |
|
|
|
|
let iso_path = |
|
|
|
|
CloudInitManager::create_cloud_init_iso(&vm_dir, &self.name, &user_data, &meta_data)?; |
|
|
|
|
debug!("Cloud-init ISO created at: {}", iso_path.display()); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
info!("Image preparation completed successfully"); |
|
|
|
|
Ok(()) |
|
|
|
|
} |
|
|
|
|
@ -390,6 +406,10 @@ impl VirtualMachine {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
if self.domain_exists()? { |
|
|
|
|
return Err(anyhow::anyhow!("Domain '{}' already exists", self.name)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if disk image exists and create if needed
|
|
|
|
|
if !Path::new(&self.disk_path).exists() { |
|
|
|
|
debug!("Disk image doesn't exist, creating it"); |
|
|
|
|
@ -458,9 +478,7 @@ impl VirtualMachine {
|
|
|
|
|
debug!("Executing command: {:?}", cmd); |
|
|
|
|
|
|
|
|
|
// Execute the command
|
|
|
|
|
let output = cmd |
|
|
|
|
.output() |
|
|
|
|
.context("Failed to execute qemu-img command")?; |
|
|
|
|
let output = cmd.output().context("Failed to execute qemu-img command")?; |
|
|
|
|
|
|
|
|
|
if !output.status.success() { |
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr); |
|
|
|
|
@ -542,11 +560,17 @@ impl VirtualMachine {
|
|
|
|
|
Ok(_) => { |
|
|
|
|
info!("Domain stopped successfully"); |
|
|
|
|
println!("Domain stopped successfully"); |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
Err(e) => { |
|
|
|
|
warn!("Warning: Failed to stop domain cleanly: {}. Continuing with undefine...", e); |
|
|
|
|
println!("Warning: Failed to stop domain cleanly: {}. Continuing with undefine...", e); |
|
|
|
|
}, |
|
|
|
|
warn!( |
|
|
|
|
"Warning: Failed to stop domain cleanly: {}. Continuing with undefine...", |
|
|
|
|
e |
|
|
|
|
); |
|
|
|
|
println!( |
|
|
|
|
"Warning: Failed to stop domain cleanly: {}. Continuing with undefine...", |
|
|
|
|
e |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
info!("Domain '{}' is already stopped", name); |
|
|
|
|
@ -576,11 +600,11 @@ impl VirtualMachine {
|
|
|
|
|
Ok(_) => { |
|
|
|
|
info!("Successfully removed disk: {}", path); |
|
|
|
|
println!("Successfully removed disk: {}", path); |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
Err(e) => { |
|
|
|
|
warn!("Warning: Failed to remove disk {}: {}", path, e); |
|
|
|
|
println!("Warning: Failed to remove disk {}: {}", path, e); |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} else if !disk_paths.is_empty() { |
|
|
|
|
@ -617,33 +641,27 @@ impl VirtualMachine {
|
|
|
|
|
for domain in active_domains { |
|
|
|
|
let name = domain.get_name().context("Failed to get domain name")?; |
|
|
|
|
let id = domain.get_id(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Get state
|
|
|
|
|
let state = match domain.get_state() { |
|
|
|
|
Ok((state, _)) => { |
|
|
|
|
match state { |
|
|
|
|
virt::sys::VIR_DOMAIN_RUNNING => DomainState::Running, |
|
|
|
|
virt::sys::VIR_DOMAIN_PAUSED => DomainState::Paused, |
|
|
|
|
virt::sys::VIR_DOMAIN_SHUTDOWN => DomainState::Shutdown, |
|
|
|
|
virt::sys::VIR_DOMAIN_SHUTOFF => DomainState::Shutoff, |
|
|
|
|
virt::sys::VIR_DOMAIN_CRASHED => DomainState::Crashed, |
|
|
|
|
_ => DomainState::Unknown, |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
Ok((state, _)) => match state { |
|
|
|
|
virt::sys::VIR_DOMAIN_RUNNING => DomainState::Running, |
|
|
|
|
virt::sys::VIR_DOMAIN_PAUSED => DomainState::Paused, |
|
|
|
|
virt::sys::VIR_DOMAIN_SHUTDOWN => DomainState::Shutdown, |
|
|
|
|
virt::sys::VIR_DOMAIN_SHUTOFF => DomainState::Shutoff, |
|
|
|
|
virt::sys::VIR_DOMAIN_CRASHED => DomainState::Crashed, |
|
|
|
|
_ => DomainState::Unknown, |
|
|
|
|
}, |
|
|
|
|
Err(_) => DomainState::Unknown, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
domain_infos.push(DomainInfo { |
|
|
|
|
id, |
|
|
|
|
name, |
|
|
|
|
state, |
|
|
|
|
}); |
|
|
|
|
domain_infos.push(DomainInfo { id, name, state }); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Process inactive domains
|
|
|
|
|
for domain in inactive_domains { |
|
|
|
|
let name = domain.get_name().context("Failed to get domain name")?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
domain_infos.push(DomainInfo { |
|
|
|
|
id: None, |
|
|
|
|
name, |
|
|
|
|
@ -654,7 +672,12 @@ impl VirtualMachine {
|
|
|
|
|
Ok(domain_infos) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn print_domain_list(uri: Option<&str>, show_all: bool, show_running: bool, show_inactive: bool) -> Result<()> { |
|
|
|
|
pub fn print_domain_list( |
|
|
|
|
uri: Option<&str>, |
|
|
|
|
show_all: bool, |
|
|
|
|
show_running: bool, |
|
|
|
|
show_inactive: bool, |
|
|
|
|
) -> Result<()> { |
|
|
|
|
// Get domain list
|
|
|
|
|
let domains = Self::list_domains(uri)?; |
|
|
|
|
|
|
|
|
|
@ -665,7 +688,10 @@ impl VirtualMachine {
|
|
|
|
|
|
|
|
|
|
// Print header
|
|
|
|
|
println!("{:<5} {:<20} {:<10}", "ID", "Name", "State"); |
|
|
|
|
println!("{:<5} {:<20} {:<10}", "-----", "--------------------", "----------"); |
|
|
|
|
println!( |
|
|
|
|
"{:<5} {:<20} {:<10}", |
|
|
|
|
"-----", "--------------------", "----------" |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
// Print domain information
|
|
|
|
|
for domain in domains { |
|
|
|
|
@ -677,13 +703,60 @@ impl VirtualMachine {
|
|
|
|
|
let is_running = domain.state == DomainState::Running; |
|
|
|
|
let is_inactive = domain.state == DomainState::Shutoff; |
|
|
|
|
|
|
|
|
|
if show_all ||
|
|
|
|
|
(show_running && is_running) ||
|
|
|
|
|
(show_inactive && is_inactive) { |
|
|
|
|
if show_all || (show_running && is_running) || (show_inactive && is_inactive) { |
|
|
|
|
println!("{:<5} {:<20} {:<10}", id, domain.name, domain.state); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
Ok(()) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn domain_exists(&self) -> Result<bool> { |
|
|
|
|
// Ensure connection is established
|
|
|
|
|
let conn = match &self.connection { |
|
|
|
|
Some(c) => c, |
|
|
|
|
None => { |
|
|
|
|
return Err(anyhow::anyhow!( |
|
|
|
|
"Connection not established before domain_exists() call" |
|
|
|
|
)); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
// Try to lookup the domain by name
|
|
|
|
|
match virt::domain::Domain::lookup_by_name(conn, &self.name) { |
|
|
|
|
Ok(_) => { |
|
|
|
|
// Domain exists
|
|
|
|
|
info!("Domain '{}' already exists", self.name); |
|
|
|
|
Ok(true) |
|
|
|
|
} |
|
|
|
|
Err(e) => { |
|
|
|
|
if e.code() == virt::error::ErrorNumber::DomExist { |
|
|
|
|
debug!("Domain '{}' does not exist", self.name); |
|
|
|
|
Ok(false) |
|
|
|
|
} else { |
|
|
|
|
// Some other error occurred
|
|
|
|
|
error!("Error checking if domain exists: {}", e); |
|
|
|
|
Err(anyhow::anyhow!("Error checking if domain exists: {}", e)) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn vm_files_exist(&self) -> bool { |
|
|
|
|
// Check if VM directory exists
|
|
|
|
|
if let Some(parent) = Path::new(&self.disk_path).parent() { |
|
|
|
|
if parent.exists() { |
|
|
|
|
debug!("VM directory already exists: {}", parent.display()); |
|
|
|
|
return true; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if disk file exists
|
|
|
|
|
if Path::new(&self.disk_path).exists() { |
|
|
|
|
debug!("VM disk already exists: {}", self.disk_path); |
|
|
|
|
return true; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
false |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|