From 1e8394b3c42d8f6fcaacc3ea6a538f26263d7ec4 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 21 May 2025 22:32:08 +0200 Subject: [PATCH] Add QCow2 file format support on backend --- .../src/libvirt_rest_structures/vm.rs | 12 +- virtweb_backend/src/utils/disks_utils.rs | 133 ----------- virtweb_backend/src/utils/file_disks_utils.rs | 208 ++++++++++++++++++ virtweb_backend/src/utils/mod.rs | 2 +- virtweb_docs/REFERENCE.md | 11 + 5 files changed, 228 insertions(+), 138 deletions(-) delete mode 100644 virtweb_backend/src/utils/disks_utils.rs create mode 100644 virtweb_backend/src/utils/file_disks_utils.rs create mode 100644 virtweb_docs/REFERENCE.md diff --git a/virtweb_backend/src/libvirt_rest_structures/vm.rs b/virtweb_backend/src/libvirt_rest_structures/vm.rs index 570b65c..ea86965 100644 --- a/virtweb_backend/src/libvirt_rest_structures/vm.rs +++ b/virtweb_backend/src/libvirt_rest_structures/vm.rs @@ -4,7 +4,7 @@ use crate::libvirt_lib_structures::XMLUuid; use crate::libvirt_lib_structures::domain::*; use crate::libvirt_rest_structures::LibVirtStructError; use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; -use crate::utils::disks_utils::Disk; +use crate::utils::file_disks_utils::{DiskFormat, FileDisk}; use crate::utils::files_utils; use crate::utils::files_utils::convert_size_unit_to_mb; use lazy_regex::regex; @@ -78,7 +78,7 @@ pub struct VMInfo { /// Attach ISO file(s) pub iso_files: Vec, /// Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest - pub disks: Vec, + pub disks: Vec, /// Network cards pub networks: Vec, /// Add a TPM v2.0 module @@ -129,6 +129,7 @@ impl VMInfo { let mut disks = vec![]; + // Add ISO files for iso_file in &self.iso_files { if !files_utils::check_file_name(iso_file) { return Err(StructureExtraction("ISO filename is invalid!").into()); @@ -267,7 +268,10 @@ impl VMInfo { device: "disk".to_string(), driver: DiskDriverXML { name: "qemu".to_string(), - r#type: "raw".to_string(), + r#type: match disk.format { + DiskFormat::Raw { .. } => "raw".to_string(), + DiskFormat::QCow2 => "qcow2".to_string(), + }, cache: "none".to_string(), }, source: DiskSourceXML { @@ -429,7 +433,7 @@ impl VMInfo { .disks .iter() .filter(|d| d.device == "disk") - .map(|d| Disk::load_from_file(&d.source.file).unwrap()) + .map(|d| FileDisk:: load_from_file(&d.source.file).unwrap()) .collect(), networks: domain diff --git a/virtweb_backend/src/utils/disks_utils.rs b/virtweb_backend/src/utils/disks_utils.rs deleted file mode 100644 index 00054d7..0000000 --- a/virtweb_backend/src/utils/disks_utils.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::app_config::AppConfig; -use crate::constants; -use crate::libvirt_lib_structures::XMLUuid; -use crate::utils::files_utils; -use lazy_regex::regex; -use std::os::linux::fs::MetadataExt; -use std::path::{Path, PathBuf}; -use std::process::Command; - -#[derive(thiserror::Error, Debug)] -enum DisksError { - #[error("DiskParseError: {0}")] - Parse(&'static str), - #[error("DiskConfigError: {0}")] - Config(&'static str), - #[error("DiskCreateError")] - Create, -} - -/// Type of disk allocation -#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum DiskAllocType { - Fixed, - Sparse, -} - -#[derive(serde::Serialize, serde::Deserialize)] -pub struct Disk { - /// Disk size, in megabytes - pub size: usize, - /// Disk name - pub name: String, - pub alloc_type: DiskAllocType, - /// Set this variable to true to delete the disk - pub delete: bool, -} - -impl Disk { - pub fn load_from_file(path: &str) -> anyhow::Result { - let file = Path::new(path); - - if !file.is_file() { - return Err(DisksError::Parse("Path is not a file!").into()); - } - - let metadata = file.metadata()?; - - // Approximate estimation - let is_sparse = metadata.len() / 512 >= metadata.st_blocks(); - - Ok(Self { - size: metadata.len() as usize / (1000 * 1000), - name: path.rsplit_once('/').unwrap().1.to_string(), - alloc_type: match is_sparse { - true => DiskAllocType::Sparse, - false => DiskAllocType::Fixed, - }, - delete: false, - }) - } - - pub fn check_config(&self) -> anyhow::Result<()> { - if constants::DISK_NAME_MIN_LEN > self.name.len() - || constants::DISK_NAME_MAX_LEN < self.name.len() - { - return Err(DisksError::Config("Disk name length is invalid").into()); - } - - if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) { - return Err(DisksError::Config("Disk name contains invalid characters!").into()); - } - - if self.size < constants::DISK_SIZE_MIN || self.size > constants::DISK_SIZE_MAX { - return Err(DisksError::Config("Disk size is invalid!").into()); - } - - Ok(()) - } - - /// Get disk path - pub fn disk_path(&self, id: XMLUuid) -> PathBuf { - let domain_dir = AppConfig::get().vm_storage_path(id); - domain_dir.join(&self.name) - } - - /// Apply disk configuration - pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> { - self.check_config()?; - - let file = self.disk_path(id); - files_utils::create_directory_if_missing(file.parent().unwrap())?; - - // Delete file if requested - if self.delete { - if !file.exists() { - log::debug!("File {file:?} does not exists, so it was not deleted"); - return Ok(()); - } - - log::info!("Deleting {file:?}"); - std::fs::remove_file(file)?; - return Ok(()); - } - - if file.exists() { - log::debug!("File {file:?} does not exists, so it was not touched"); - return Ok(()); - } - - let mut cmd = Command::new("/usr/bin/dd"); - cmd.arg("if=/dev/zero") - .arg(format!("of={}", file.to_string_lossy())) - .arg("bs=1M"); - - match self.alloc_type { - DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)), - DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"), - }; - - let res = cmd.output()?; - - if !res.status.success() { - log::error!( - "Failed to create disk! stderr={} stdout={}", - String::from_utf8_lossy(&res.stderr), - String::from_utf8_lossy(&res.stdout) - ); - return Err(DisksError::Create.into()); - } - - Ok(()) - } -} diff --git a/virtweb_backend/src/utils/file_disks_utils.rs b/virtweb_backend/src/utils/file_disks_utils.rs new file mode 100644 index 0000000..6f1b0d6 --- /dev/null +++ b/virtweb_backend/src/utils/file_disks_utils.rs @@ -0,0 +1,208 @@ +use crate::app_config::AppConfig; +use crate::constants; +use crate::libvirt_lib_structures::XMLUuid; +use crate::utils::files_utils; +use lazy_regex::regex; +use std::os::linux::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(thiserror::Error, Debug)] +enum DisksError { + #[error("DiskParseError: {0}")] + Parse(&'static str), + #[error("DiskConfigError: {0}")] + Config(&'static str), + #[error("DiskCreateError")] + Create, +} + +/// Type of disk allocation +#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum DiskAllocType { + Fixed, + Sparse, +} + +/// Disk allocation type +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(tag = "format")] +pub enum DiskFormat { + Raw { + /// Type of disk allocation + alloc_type: DiskAllocType, + }, + QCow2, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct FileDisk { + /// Disk name + pub name: String, + /// Disk size, in megabytes + size: usize, + /// Disk format + #[serde(flatten)] + pub format: DiskFormat, + /// Set this variable to true to delete the disk + pub delete: bool, +} + +impl FileDisk { + pub fn load_from_file(path: &str) -> anyhow::Result { + let file = Path::new(path); + + if !file.is_file() { + return Err(DisksError::Parse("Path is not a file!").into()); + } + + let metadata = file.metadata()?; + let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("disk"); + let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw"); + + // Approximate raw file estimation + let is_raw_sparse = metadata.len() / 512 >= metadata.st_blocks(); + + let format = match ext { + "qcow2" => DiskFormat::QCow2, + "raw" => DiskFormat::Raw { + alloc_type: match is_raw_sparse { + true => DiskAllocType::Sparse, + false => DiskAllocType::Fixed, + }, + }, + _ => anyhow::bail!("Unsupported disk extension: {ext}!"), + }; + + Ok(Self { + name: name.to_string(), + size: match format { + DiskFormat::Raw { .. } => metadata.len() as usize / (1000 * 1000), + DiskFormat::QCow2 => qcow_virt_size(path)? / (1000 * 1000), + }, + format, + delete: false, + }) + } + + pub fn check_config(&self) -> anyhow::Result<()> { + if constants::DISK_NAME_MIN_LEN > self.name.len() + || constants::DISK_NAME_MAX_LEN < self.name.len() + { + return Err(DisksError::Config("Disk name length is invalid").into()); + } + + if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) { + return Err(DisksError::Config("Disk name contains invalid characters!").into()); + } + + // Check disk size + + if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) { + return Err(DisksError::Config("Disk size is invalid!").into()); + } + + Ok(()) + } + + /// Get disk path + pub fn disk_path(&self, id: XMLUuid) -> PathBuf { + let domain_dir = AppConfig::get().vm_storage_path(id); + let file_name = match self.format { + DiskFormat::Raw { .. } => self.name.to_string(), + DiskFormat::QCow2 => format!("{}.qcow2", self.name), + }; + domain_dir.join(&file_name) + } + + /// Apply disk configuration + pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> { + self.check_config()?; + + let file = self.disk_path(id); + files_utils::create_directory_if_missing(file.parent().unwrap())?; + + // Delete file if requested + if self.delete { + if !file.exists() { + log::debug!("File {file:?} does not exists, so it was not deleted"); + return Ok(()); + } + + log::info!("Deleting {file:?}"); + std::fs::remove_file(file)?; + return Ok(()); + } + + if file.exists() { + log::debug!("File {file:?} does not exists, so it was not touched"); + return Ok(()); + } + + // Prepare command to create file + let res = match self.format { + DiskFormat::Raw { alloc_type } => { + let mut cmd = Command::new("/usr/bin/dd"); + cmd.arg("if=/dev/zero") + .arg(format!("of={}", file.to_string_lossy())) + .arg("bs=1M"); + + match alloc_type { + DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)), + DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"), + }; + + cmd.output()? + } + + DiskFormat::QCow2 => { + let mut cmd = Command::new("/usr/bin/qemu-img"); + cmd.arg("create") + .arg("-f") + .arg("qcow2") + .arg(file) + .arg(format!("{}M", self.size)); + + cmd.output()? + } + }; + + // Execute Linux command + if !res.status.success() { + log::error!( + "Failed to create disk! stderr={} stdout={}", + String::from_utf8_lossy(&res.stderr), + String::from_utf8_lossy(&res.stdout) + ); + return Err(DisksError::Create.into()); + } + + Ok(()) + } +} + +#[derive(serde::Deserialize)] +struct QCowInfoOutput { + #[serde(rename = "virtual-size")] + virtual_size: usize, +} + +/// Get QCow2 virtual size +fn qcow_virt_size(path: &str) -> anyhow::Result { + // Run qemu-img + let mut cmd = Command::new("qemu-img"); + cmd.args(["info", path, "--output", "json", "--force-share"]); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!( + "qemu-img info failed, status: {}, stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + let res_json = String::from_utf8(output.stdout)?; + + // Decode JSON + let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?; + Ok(decoded.virtual_size) +} diff --git a/virtweb_backend/src/utils/mod.rs b/virtweb_backend/src/utils/mod.rs index f10b3e2..991bedf 100644 --- a/virtweb_backend/src/utils/mod.rs +++ b/virtweb_backend/src/utils/mod.rs @@ -1,4 +1,4 @@ -pub mod disks_utils; +pub mod file_disks_utils; pub mod files_utils; pub mod net_utils; pub mod rand_utils; diff --git a/virtweb_docs/REFERENCE.md b/virtweb_docs/REFERENCE.md new file mode 100644 index 0000000..9a65a12 --- /dev/null +++ b/virtweb_docs/REFERENCE.md @@ -0,0 +1,11 @@ +## References + +### LibVirt XML documentation +* Online: https://libvirt.org/format.html + +* Offline with Ubuntu: + +```bash +sudo apt install libvirt-doc +firefox /usr/share/doc/libvirt-doc/html/index.html +``` \ No newline at end of file