Compare commits
	
		
			19 Commits
		
	
	
		
			5cdbb58c03
			...
			9334b984ae
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9334b984ae | |||
| d5fbc24c96 | |||
| d765f9c2c3 | |||
| 21fd5de139 | |||
| 42f22c110c | |||
| 9822c5a72a | |||
| 452a395525 | |||
| 80d81c34bb | |||
| b9353326f5 | |||
| 3ffc64f129 | |||
| e869517bb1 | |||
| 90f4bf35e9 | |||
| 80d6fe0298 | |||
| e017fe96d5 | |||
| e7ac0198ab | |||
| 927a51cda7 | |||
| 615dc1ed83 | |||
| 20de618568 | |||
| 7451f1b7b4 | 
							
								
								
									
										4
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -3413,9 +3413,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "tokio" | ||||
| version = "1.45.0" | ||||
| version = "1.45.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" | ||||
| checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" | ||||
| dependencies = [ | ||||
|  "backtrace", | ||||
|  "bytes", | ||||
|   | ||||
| @@ -36,7 +36,7 @@ lazy-regex = "3.4.1" | ||||
| thiserror = "2.0.12" | ||||
| image = "0.25.6" | ||||
| rand = "0.9.1" | ||||
| tokio = { version = "1.45.0", features = ["rt", "time", "macros"] } | ||||
| tokio = { version = "1.45.1", features = ["rt", "time", "macros"] } | ||||
| futures = "0.3.31" | ||||
| ipnetwork = { version = "0.21.1", features = ["serde"] } | ||||
| num = "0.4.3" | ||||
|   | ||||
| @@ -245,7 +245,7 @@ impl AppConfig { | ||||
|         storage_path.canonicalize().unwrap() | ||||
|     } | ||||
|  | ||||
|     /// Get iso storage directory | ||||
|     /// Get iso files storage directory | ||||
|     pub fn iso_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("iso") | ||||
|     } | ||||
| @@ -265,15 +265,17 @@ impl AppConfig { | ||||
|         self.vnc_sockets_path().join(format!("vnc-{}", name)) | ||||
|     } | ||||
|  | ||||
|     /// Get VM vnc sockets directory | ||||
|     pub fn disks_storage_path(&self) -> PathBuf { | ||||
|     /// Get VM root disks storage directory | ||||
|     pub fn root_vm_disks_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("disks") | ||||
|     } | ||||
|  | ||||
|     /// Get specific VM disk storage directory | ||||
|     pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         self.disks_storage_path().join(id.as_string()) | ||||
|         self.root_vm_disks_storage_path().join(id.as_string()) | ||||
|     } | ||||
|  | ||||
|     /// Get the path were VM definitions are backed up | ||||
|     pub fn definitions_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("definitions") | ||||
|     } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
|  | ||||
| /// Name of the cookie that contains session information | ||||
| pub const SESSION_COOKIE_NAME: &str = "X-auth-token"; | ||||
|  | ||||
| @@ -47,10 +49,10 @@ pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
| pub const DISK_NAME_MAX_LEN: usize = 10; | ||||
|  | ||||
| /// Disk size min (B) | ||||
| pub const DISK_SIZE_MIN: usize = 100 * 1000 * 1000; | ||||
| pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | ||||
|  | ||||
| /// Disk size max (B) | ||||
| pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 1000 * 1000 * 2; | ||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | ||||
|  | ||||
| /// Net nat entry comment max size | ||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | ||||
| @@ -121,3 +123,15 @@ pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img"; | ||||
|  | ||||
| /// IP program path | ||||
| pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | ||||
|  | ||||
| /// Copy program path | ||||
| pub const COPY_PROGRAM: &str = "/bin/cp"; | ||||
|  | ||||
| /// Gzip program path | ||||
| pub const GZIP_PROGRAM: &str = "/usr/bin/gzip"; | ||||
|  | ||||
| /// Bash program | ||||
| pub const BASH_PROGRAM: &str = "/usr/bin/bash"; | ||||
|  | ||||
| /// DD program | ||||
| pub const DD_PROGRAM: &str = "/usr/bin/dd"; | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::utils::file_disks_utils::DiskFileInfo; | ||||
| use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo}; | ||||
| use crate::utils::files_utils; | ||||
| use actix_files::NamedFile; | ||||
| use actix_multipart::form::MultipartForm; | ||||
| use actix_multipart::form::tempfile::TempFile; | ||||
| use actix_web::HttpResponse; | ||||
| use actix_web::{HttpRequest, HttpResponse, web}; | ||||
|  | ||||
| #[derive(Debug, MultipartForm)] | ||||
| pub struct UploadDiskImageForm { | ||||
| @@ -67,3 +68,97 @@ pub async fn get_list() -> HttpResult { | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(list)) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct DiskFilePath { | ||||
|     filename: String, | ||||
| } | ||||
|  | ||||
| /// Download disk image | ||||
| pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|  | ||||
|     let file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|  | ||||
|     if !file_path.exists() { | ||||
|         return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); | ||||
|     } | ||||
|  | ||||
|     Ok(NamedFile::open(file_path)?.into_response(&req)) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct ConvertDiskImageRequest { | ||||
|     dest_file_name: String, | ||||
|     #[serde(flatten)] | ||||
|     format: DiskFileFormat, | ||||
| } | ||||
|  | ||||
| /// Convert disk image into a new format | ||||
| pub async fn convert( | ||||
|     p: web::Path<DiskFilePath>, | ||||
|     req: web::Json<ConvertDiskImageRequest>, | ||||
| ) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid src file name!")); | ||||
|     } | ||||
|  | ||||
|     if !files_utils::check_file_name(&req.dest_file_name) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid destination file name!")); | ||||
|     } | ||||
|     if !req | ||||
|         .format | ||||
|         .ext() | ||||
|         .iter() | ||||
|         .any(|e| req.dest_file_name.ends_with(e)) | ||||
|     { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); | ||||
|     } | ||||
|  | ||||
|     let src_file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|  | ||||
|     let src = DiskFileInfo::load_file(&src_file_path)?; | ||||
|  | ||||
|     let dst_file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&req.dest_file_name); | ||||
|  | ||||
|     if dst_file_path.exists() { | ||||
|         return Ok(HttpResponse::Conflict().json("Specified destination file already exists!")); | ||||
|     } | ||||
|  | ||||
|     // Perform conversion | ||||
|     if let Err(e) = src.convert(&dst_file_path, req.format) { | ||||
|         log::error!("Disk file conversion error: {e}"); | ||||
|         return Ok( | ||||
|             HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}")) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(src)) | ||||
| } | ||||
|  | ||||
| /// Delete a disk image | ||||
| pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|  | ||||
|     let file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|  | ||||
|     if !file_path.exists() { | ||||
|         return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); | ||||
|     } | ||||
|  | ||||
|     std::fs::remove_file(file_path)?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
|   | ||||
| @@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult { | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct DownloadFilePath { | ||||
| pub struct IsoFilePath { | ||||
|     filename: String, | ||||
| } | ||||
|  | ||||
| /// Download ISO file | ||||
| pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult { | ||||
| pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
| @@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> | ||||
| } | ||||
|  | ||||
| /// Delete ISO file | ||||
| pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult { | ||||
| pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|   | ||||
| @@ -46,6 +46,7 @@ struct ServerConstraints { | ||||
|     memory_size: LenConstraints, | ||||
|     disk_name_size: LenConstraints, | ||||
|     disk_size: LenConstraints, | ||||
|     disk_image_name_size: LenConstraints, | ||||
|     net_name_size: LenConstraints, | ||||
|     net_title_size: LenConstraints, | ||||
|     net_nat_comment_size: LenConstraints, | ||||
| @@ -87,10 +88,12 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|                 max: DISK_NAME_MAX_LEN, | ||||
|             }, | ||||
|             disk_size: LenConstraints { | ||||
|                 min: DISK_SIZE_MIN, | ||||
|                 max: DISK_SIZE_MAX, | ||||
|                 min: DISK_SIZE_MIN.as_bytes(), | ||||
|                 max: DISK_SIZE_MAX.as_bytes(), | ||||
|             }, | ||||
|  | ||||
|             disk_image_name_size: LenConstraints { min: 5, max: 220 }, | ||||
|  | ||||
|             net_name_size: LenConstraints { min: 2, max: 50 }, | ||||
|             net_title_size: LenConstraints { min: 0, max: 50 }, | ||||
|             net_nat_comment_size: LenConstraints { | ||||
|   | ||||
| @@ -4,9 +4,9 @@ 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::file_disks_utils::{VMDiskFormat, VMFileDisk}; | ||||
| use crate::utils::files_utils; | ||||
| use crate::utils::files_utils::convert_size_unit_to_mb; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk}; | ||||
| use lazy_regex::regex; | ||||
| use num::Integer; | ||||
|  | ||||
|   | ||||
| @@ -60,7 +60,8 @@ async fn main() -> std::io::Result<()> { | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); | ||||
|     files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path()) | ||||
|         .unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap(); | ||||
| @@ -344,6 +345,18 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/disk_images/list", | ||||
|                 web::get().to(disk_images_controller::get_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}", | ||||
|                 web::get().to(disk_images_controller::download), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}/convert", | ||||
|                 web::post().to(disk_images_controller::convert), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}", | ||||
|                 web::delete().to(disk_images_controller::delete), | ||||
|             ) | ||||
|             // API tokens controller | ||||
|             .route( | ||||
|                 "/api/token/create", | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::files_utils; | ||||
| use lazy_regex::regex; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use std::fs::File; | ||||
| use std::os::linux::fs::MetadataExt; | ||||
| use std::path::{Path, PathBuf}; | ||||
| use std::process::Command; | ||||
| @@ -12,193 +11,48 @@ use std::time::UNIX_EPOCH; | ||||
| enum DisksError { | ||||
|     #[error("DiskParseError: {0}")] | ||||
|     Parse(&'static str), | ||||
|     #[error("DiskConfigError: {0}")] | ||||
|     Config(&'static str), | ||||
|     #[error("DiskCreateError")] | ||||
|     Create, | ||||
|     #[error("DiskConvertError: {0}")] | ||||
|     Convert(String), | ||||
| } | ||||
|  | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] | ||||
| pub enum VMDiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum VMDiskFormat { | ||||
|     Raw { | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: VMDiskAllocType, | ||||
|     }, | ||||
|     QCow2, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct VMFileDisk { | ||||
|     /// Disk name | ||||
|     pub name: String, | ||||
|     /// Disk size, in bytes | ||||
|     pub size: usize, | ||||
|     /// Disk format | ||||
|     #[serde(flatten)] | ||||
|     pub format: VMDiskFormat, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
|  | ||||
| impl VMFileDisk { | ||||
|     pub fn load_from_file(path: &str) -> anyhow::Result<Self> { | ||||
|         let file = Path::new(path); | ||||
|  | ||||
|         let info = DiskFileInfo::load_file(file)?; | ||||
|  | ||||
|         Ok(Self { | ||||
|             name: info.name, | ||||
|  | ||||
|             // Get only the virtual size of the file | ||||
|             size: match info.format { | ||||
|                 DiskFileFormat::Raw { .. } => info.file_size, | ||||
|                 DiskFileFormat::QCow2 { virtual_size } => virtual_size, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.format), | ||||
|             }, | ||||
|  | ||||
|             format: match info.format { | ||||
|                 DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { | ||||
|                     alloc_type: match is_sparse { | ||||
|                         true => VMDiskAllocType::Sparse, | ||||
|                         false => VMDiskAllocType::Fixed, | ||||
|                     }, | ||||
|                 }, | ||||
|                 DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.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 { | ||||
|             VMDiskFormat::Raw { .. } => self.name.to_string(), | ||||
|             VMDiskFormat::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 { | ||||
|             VMDiskFormat::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 { | ||||
|                     VMDiskAllocType::Fixed => cmd.arg(format!("count={}", self.size_mb())), | ||||
|                     VMDiskAllocType::Sparse => { | ||||
|                         cmd.arg(format!("seek={}", self.size_mb())).arg("count=0") | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|  | ||||
|             VMDiskFormat::QCow2 => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("create") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(file) | ||||
|                     .arg(format!("{}M", self.size_mb())); | ||||
|  | ||||
|                 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(()) | ||||
|     } | ||||
|  | ||||
|     /// Get the size of file disk in megabytes | ||||
|     pub fn size_mb(&self) -> usize { | ||||
|         self.size / (1000 * 1000) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| #[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum DiskFileFormat { | ||||
|     Raw { is_sparse: bool }, | ||||
|     QCow2 { virtual_size: usize }, | ||||
|     Raw { | ||||
|         #[serde(default)] | ||||
|         is_sparse: bool, | ||||
|     }, | ||||
|     QCow2 { | ||||
|         #[serde(default)] | ||||
|         virtual_size: FileSize, | ||||
|     }, | ||||
|     CompressedRaw, | ||||
|     CompressedQCow2, | ||||
| } | ||||
|  | ||||
| impl DiskFileFormat { | ||||
|     pub fn ext(&self) -> &'static [&'static str] { | ||||
|         match self { | ||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||
|             DiskFileFormat::CompressedRaw => &["raw.gz"], | ||||
|             DiskFileFormat::CompressedQCow2 => &["qcow2.gz"], | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Disk file information | ||||
| #[derive(serde::Serialize)] | ||||
| pub struct DiskFileInfo { | ||||
|     file_size: usize, | ||||
|     pub file_path: PathBuf, | ||||
|     pub file_size: FileSize, | ||||
|     #[serde(flatten)] | ||||
|     format: DiskFileFormat, | ||||
|     file_name: String, | ||||
|     name: String, | ||||
|     created: u64, | ||||
|     pub format: DiskFileFormat, | ||||
|     pub file_name: String, | ||||
|     pub name: String, | ||||
|     pub created: u64, | ||||
| } | ||||
|  | ||||
| impl DiskFileInfo { | ||||
| @@ -234,8 +88,9 @@ impl DiskFileInfo { | ||||
|         }; | ||||
|  | ||||
|         Ok(Self { | ||||
|             file_path: file.to_path_buf(), | ||||
|             name, | ||||
|             file_size: metadata.len() as usize, | ||||
|             file_size: FileSize::from_bytes(metadata.len() as usize), | ||||
|             format, | ||||
|             file_name: file | ||||
|                 .file_name() | ||||
| @@ -249,6 +104,218 @@ impl DiskFileInfo { | ||||
|                 .as_secs(), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Create a new empty disk | ||||
|     pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> { | ||||
|         // Prepare command to create file | ||||
|         let res = match format { | ||||
|             DiskFileFormat::Raw { is_sparse } => { | ||||
|                 let mut cmd = Command::new("/usr/bin/dd"); | ||||
|                 cmd.arg("if=/dev/zero") | ||||
|                     .arg(format!("of={}", file.to_string_lossy())) | ||||
|                     .arg("bs=1M"); | ||||
|  | ||||
|                 match is_sparse { | ||||
|                     false => cmd.arg(format!("count={}", size.as_mb())), | ||||
|                     true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"), | ||||
|                 }; | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|  | ||||
|             DiskFileFormat::QCow2 { virtual_size } => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("create") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(file) | ||||
|                     .arg(format!("{}M", virtual_size.as_mb())); | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|             _ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"), | ||||
|         }; | ||||
|  | ||||
|         // 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(()) | ||||
|     } | ||||
|  | ||||
|     /// Copy / convert file disk image into a new destination with optionally a new file format | ||||
|     pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> { | ||||
|         // Create a temporary directory to perform the operation | ||||
|         let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; | ||||
|         let temp_file = temp_dir | ||||
|             .path() | ||||
|             .join(format!("temp_file.{}", dest_format.ext()[0])); | ||||
|  | ||||
|         // Prepare the conversion | ||||
|         let mut cmd = match (self.format, dest_format) { | ||||
|             // Decompress QCow2 | ||||
|             (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Convert QCow2 to Raw file | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert").arg(&self.file_path).arg(&temp_file); | ||||
|  | ||||
|                 if !is_sparse { | ||||
|                     cmd.args(["-S", "0"]); | ||||
|                 } | ||||
|  | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of | ||||
|             // file | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-O") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Convert Raw to QCow2 file | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert").arg(&self.file_path).arg(&temp_file); | ||||
|  | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Render raw file non sparse | ||||
|             (DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||
|                 cmd.arg("--sparse=never") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Render raw file sparse | ||||
|             (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::DD_PROGRAM); | ||||
|                 cmd.arg("conv=sparse") | ||||
|                     .arg(format!("if={}", self.file_path.display())) | ||||
|                     .arg(format!("of={}", temp_file.display())); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress Raw | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to not sparse file | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to sparse file | ||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::BASH_PROGRAM); | ||||
|                 cmd.arg("-c").arg(format!( | ||||
|                     "{} -d -c {} | {} conv=sparse of={}", | ||||
|                     constants::GZIP_PROGRAM, | ||||
|                     self.file_path.display(), | ||||
|                     constants::DD_PROGRAM, | ||||
|                     temp_file.display() | ||||
|                 )); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Dumb copy of file | ||||
|             (a, b) if a == b => { | ||||
|                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||
|                 cmd.arg("--sparse=auto") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // By default, conversion is unsupported | ||||
|             (src, dest) => { | ||||
|                 return Err(DisksError::Convert(format!( | ||||
|                     "Conversion from {src:?} to {dest:?} is not supported!" | ||||
|                 )) | ||||
|                 .into()); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Execute the conversion | ||||
|         let command_s = format!( | ||||
|             "{} {}", | ||||
|             cmd.get_program().display(), | ||||
|             cmd.get_args() | ||||
|                 .map(|a| format!("'{}'", a.display())) | ||||
|                 .collect::<Vec<String>>() | ||||
|                 .join(" ") | ||||
|         ); | ||||
|         let cmd_output = cmd.output()?; | ||||
|         if !cmd_output.status.success() { | ||||
|             return Err(DisksError::Convert(format!( | ||||
|                 "Command failed:\n{command_s}\nStatus: {}\nstdout: {}\nstderr: {}", | ||||
|                 cmd_output.status, | ||||
|                 String::from_utf8_lossy(&cmd_output.stdout), | ||||
|                 String::from_utf8_lossy(&cmd_output.stderr) | ||||
|             )) | ||||
|             .into()); | ||||
|         } | ||||
|  | ||||
|         // Check the file was created | ||||
|         if !temp_file.is_file() { | ||||
|             return Err(DisksError::Convert( | ||||
|                 "Temporary was not created after execution of command!".to_string(), | ||||
|             ) | ||||
|             .into()); | ||||
|         } | ||||
|  | ||||
|         // Move the file to its final location | ||||
|         std::fs::rename(temp_file, dest_file)?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| @@ -258,7 +325,7 @@ struct QCowInfoOutput { | ||||
| } | ||||
|  | ||||
| /// Get QCow2 virtual size | ||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> { | ||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
|     // Run qemu-img | ||||
|     let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|     cmd.args([ | ||||
| @@ -281,5 +348,5 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> { | ||||
|  | ||||
|     // Decode JSON | ||||
|     let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?; | ||||
|     Ok(decoded.virtual_size) | ||||
|     Ok(FileSize::from_bytes(decoded.virtual_size)) | ||||
| } | ||||
|   | ||||
							
								
								
									
										37
									
								
								virtweb_backend/src/utils/file_size_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								virtweb_backend/src/utils/file_size_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| #[derive( | ||||
|     serde::Serialize, | ||||
|     serde::Deserialize, | ||||
|     Copy, | ||||
|     Clone, | ||||
|     Debug, | ||||
|     Eq, | ||||
|     PartialEq, | ||||
|     PartialOrd, | ||||
|     Ord, | ||||
|     Default, | ||||
| )] | ||||
| pub struct FileSize(usize); | ||||
|  | ||||
| impl FileSize { | ||||
|     pub const fn from_bytes(size: usize) -> Self { | ||||
|         Self(size) | ||||
|     } | ||||
|  | ||||
|     pub const fn from_mb(mb: usize) -> Self { | ||||
|         Self(mb * 1000 * 1000) | ||||
|     } | ||||
|  | ||||
|     pub const fn from_gb(gb: usize) -> Self { | ||||
|         Self(gb * 1000 * 1000 * 1000) | ||||
|     } | ||||
|  | ||||
|     /// Get file size as bytes | ||||
|     pub fn as_bytes(&self) -> usize { | ||||
|         self.0 | ||||
|     } | ||||
|  | ||||
|     /// Get file size as megabytes | ||||
|     pub fn as_mb(&self) -> usize { | ||||
|         self.0 / (1000 * 1000) | ||||
|     } | ||||
| } | ||||
| @@ -1,7 +1,9 @@ | ||||
| pub mod exec_utils; | ||||
| pub mod file_disks_utils; | ||||
| pub mod file_size_utils; | ||||
| pub mod files_utils; | ||||
| pub mod net_utils; | ||||
| pub mod rand_utils; | ||||
| pub mod time_utils; | ||||
| pub mod url_utils; | ||||
| pub mod vm_file_disks_utils; | ||||
|   | ||||
							
								
								
									
										146
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo}; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use crate::utils::files_utils; | ||||
| use lazy_regex::regex; | ||||
| use std::path::{Path, PathBuf}; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum VMDisksError { | ||||
|     #[error("DiskConfigError: {0}")] | ||||
|     Config(&'static str), | ||||
| } | ||||
|  | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)] | ||||
| pub enum VMDiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum VMDiskFormat { | ||||
|     Raw { | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: VMDiskAllocType, | ||||
|     }, | ||||
|     QCow2, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct VMFileDisk { | ||||
|     /// Disk name | ||||
|     pub name: String, | ||||
|     /// Disk size, in bytes | ||||
|     pub size: FileSize, | ||||
|     /// Disk format | ||||
|     #[serde(flatten)] | ||||
|     pub format: VMDiskFormat, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
|  | ||||
| impl VMFileDisk { | ||||
|     pub fn load_from_file(path: &str) -> anyhow::Result<Self> { | ||||
|         let file = Path::new(path); | ||||
|  | ||||
|         let info = DiskFileInfo::load_file(file)?; | ||||
|  | ||||
|         Ok(Self { | ||||
|             name: info.name, | ||||
|  | ||||
|             // Get only the virtual size of the file | ||||
|             size: match info.format { | ||||
|                 DiskFileFormat::Raw { .. } => info.file_size, | ||||
|                 DiskFileFormat::QCow2 { virtual_size } => virtual_size, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.format), | ||||
|             }, | ||||
|  | ||||
|             format: match info.format { | ||||
|                 DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { | ||||
|                     alloc_type: match is_sparse { | ||||
|                         true => VMDiskAllocType::Sparse, | ||||
|                         false => VMDiskAllocType::Fixed, | ||||
|                     }, | ||||
|                 }, | ||||
|                 DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.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(VMDisksError::Config("Disk name length is invalid").into()); | ||||
|         } | ||||
|  | ||||
|         if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) { | ||||
|             return Err(VMDisksError::Config("Disk name contains invalid characters!").into()); | ||||
|         } | ||||
|  | ||||
|         // Check disk size | ||||
|         if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) { | ||||
|             return Err(VMDisksError::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 { | ||||
|             VMDiskFormat::Raw { .. } => self.name.to_string(), | ||||
|             VMDiskFormat::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(()); | ||||
|         } | ||||
|  | ||||
|         // Create disk file | ||||
|         DiskFileInfo::create( | ||||
|             &file, | ||||
|             match self.format { | ||||
|                 VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw { | ||||
|                     is_sparse: alloc_type == VMDiskAllocType::Sparse, | ||||
|                 }, | ||||
|                 VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                     virtual_size: self.size, | ||||
|                 }, | ||||
|             }, | ||||
|             self.size, | ||||
|         )?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| @@ -34,7 +34,7 @@ docker compose up | ||||
| sudo mkdir /var/virtweb | ||||
| sudo chown $USER:$USER /var/virtweb | ||||
| cd virtweb_backend | ||||
| cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" | ||||
| cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173" | ||||
| ``` | ||||
|  | ||||
| 7. Run the frontend | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
|  | ||||
| export type DiskImageFormat = | ||||
|   | { format: "Raw"; is_sparse: boolean } | ||||
|   | { format: "QCow2"; virtual_size?: number } | ||||
|   | { format: "CompressedQCow2" } | ||||
|   | { format: "CompressedRaw" }; | ||||
|  | ||||
| export type DiskImage = { | ||||
|   file_size: number; | ||||
|   file_name: string; | ||||
|   name: string; | ||||
|   created: number; | ||||
| } & ( | ||||
|   | { format: "Raw"; is_sparse: boolean } | ||||
|   | { format: "QCow2"; virtual_size: number } | ||||
|   | { format: "CompressedQCow2" } | ||||
|   | { format: "CompressedRaw" } | ||||
| ); | ||||
| } & DiskImageFormat; | ||||
|  | ||||
| export class DiskImageApi { | ||||
|   /** | ||||
| @@ -42,4 +43,47 @@ export class DiskImageApi { | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Download disk image file | ||||
|    */ | ||||
|   static async Download( | ||||
|     file: DiskImage, | ||||
|     progress: (p: number) => void | ||||
|   ): Promise<Blob> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/disk_images/${file.file_name}`, | ||||
|         downProgress(e) { | ||||
|           progress(Math.floor(100 * (e.progress / e.total))); | ||||
|         }, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convert disk image file | ||||
|    */ | ||||
|   static async Convert( | ||||
|     file: DiskImage, | ||||
|     dest_file_name: string, | ||||
|     dest_format: DiskImageFormat | ||||
|   ): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "POST", | ||||
|       uri: `/disk_images/${file.file_name}/convert`, | ||||
|       jsonData: { ...dest_format, dest_file_name }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete disk image file | ||||
|    */ | ||||
|   static async Delete(file: DiskImage): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "DELETE", | ||||
|       uri: `/disk_images/${file.file_name}`, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -22,6 +22,7 @@ export interface ServerConstraints { | ||||
|   memory_size: LenConstraint; | ||||
|   disk_name_size: LenConstraint; | ||||
|   disk_size: LenConstraint; | ||||
|   disk_image_name_size: LenConstraint; | ||||
|   net_name_size: LenConstraint; | ||||
|   net_title_size: LenConstraint; | ||||
|   net_nat_comment_size: LenConstraint; | ||||
|   | ||||
							
								
								
									
										123
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| import { | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogContent, | ||||
|   DialogContentText, | ||||
|   DialogTitle, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi"; | ||||
| import { ServerApi } from "../api/ServerApi"; | ||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../hooks/providers/SnackbarProvider"; | ||||
| import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget"; | ||||
| import { CheckboxInput } from "../widgets/forms/CheckboxInput"; | ||||
| import { SelectInput } from "../widgets/forms/SelectInput"; | ||||
| import { TextInput } from "../widgets/forms/TextInput"; | ||||
|  | ||||
| export function ConvertDiskImageDialog(p: { | ||||
|   image: DiskImage; | ||||
|   onCancel: () => void; | ||||
|   onFinished: () => void; | ||||
| }): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const snackbar = useSnackbar(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [format, setFormat] = React.useState<DiskImageFormat>({ | ||||
|     format: "QCow2", | ||||
|   }); | ||||
|  | ||||
|   const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2"); | ||||
|  | ||||
|   const handleFormatChange = (value?: string) => { | ||||
|     setFormat({ format: value ?? ("QCow2" as any) }); | ||||
|  | ||||
|     if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`); | ||||
|     if (value === "CompressedQCow2") | ||||
|       setFilename(`${p.image.file_name}.qcow2.gz`); | ||||
|     if (value === "Raw") { | ||||
|       setFilename(`${p.image.file_name}.raw`); | ||||
|       // Check sparse checkbox by default | ||||
|       setFormat({ format: "Raw", is_sparse: true }); | ||||
|     } | ||||
|     if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       loadingMessage.show("Converting image..."); | ||||
|  | ||||
|       // Perform the conversion | ||||
|       await DiskImageApi.Convert(p.image, filename, format); | ||||
|  | ||||
|       p.onFinished(); | ||||
|  | ||||
|       snackbar("Conversion successful!"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to convert image!", e); | ||||
|       alert(`Failed to convert image! ${e}`); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open onClose={p.onCancel}> | ||||
|       <DialogTitle>Convert disk image</DialogTitle> | ||||
|  | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           Select the destination format for this image: | ||||
|         </DialogContentText> | ||||
|         <FileDiskImageWidget image={p.image} /> | ||||
|  | ||||
|         {/* New image format */} | ||||
|         <SelectInput | ||||
|           editable | ||||
|           label="Target format" | ||||
|           value={format.format} | ||||
|           onValueChange={handleFormatChange} | ||||
|           options={[ | ||||
|             { value: "QCow2" }, | ||||
|             { value: "Raw" }, | ||||
|             { value: "CompressedRaw" }, | ||||
|             { value: "CompressedQCow2" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
|         {/* Check for sparse file */} | ||||
|         {format.format === "Raw" && ( | ||||
|           <CheckboxInput | ||||
|             editable | ||||
|             label="Sparse file" | ||||
|             checked={format.is_sparse} | ||||
|             onValueChange={(c) => { | ||||
|               setFormat({ format: "Raw", is_sparse: c }); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
|         {/* New image name */} | ||||
|         <TextInput | ||||
|           editable | ||||
|           label="New image name" | ||||
|           value={filename} | ||||
|           onValueChange={(s) => { | ||||
|             setFilename(s ?? ""); | ||||
|           }} | ||||
|           size={ServerApi.Config.constraints.disk_image_name_size} | ||||
|           helperText="The image name shall contain the proper file extension" | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={p.onCancel}>Cancel</Button> | ||||
|         <Button onClick={handleSubmit} autoFocus> | ||||
|           Convert image | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -1,18 +1,29 @@ | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import DownloadIcon from "@mui/icons-material/Download"; | ||||
| import LoopIcon from "@mui/icons-material/Loop"; | ||||
| import RefreshIcon from "@mui/icons-material/Refresh"; | ||||
| import { | ||||
|   Alert, | ||||
|   Button, | ||||
|   CircularProgress, | ||||
|   IconButton, | ||||
|   LinearProgress, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import { DataGrid, GridColDef } from "@mui/x-data-grid"; | ||||
| import { filesize } from "filesize"; | ||||
| import React from "react"; | ||||
| import { DiskImage, DiskImageApi } from "../api/DiskImageApi"; | ||||
| import { ServerApi } from "../api/ServerApi"; | ||||
| import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog"; | ||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../hooks/providers/SnackbarProvider"; | ||||
| import { downloadBlob } from "../utils/FilesUtils"; | ||||
| import { AsyncWidget } from "../widgets/AsyncWidget"; | ||||
| import { DateWidget } from "../widgets/DateWidget"; | ||||
| import { FileInput } from "../widgets/forms/FileInput"; | ||||
| import { VirtWebPaper } from "../widgets/VirtWebPaper"; | ||||
| import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | ||||
| @@ -32,28 +43,28 @@ export function DiskImagesRoute(): React.ReactElement { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <VirtWebRouteContainer label="Disk images"> | ||||
|     <VirtWebRouteContainer | ||||
|       label="Disk images management" | ||||
|       actions={ | ||||
|         <span> | ||||
|           <Tooltip title="Refresh Disk images list"> | ||||
|             <IconButton onClick={reload}> | ||||
|               <RefreshIcon /> | ||||
|             </IconButton> | ||||
|           </Tooltip> | ||||
|         </span> | ||||
|       } | ||||
|     > | ||||
|       <AsyncWidget | ||||
|         loadKey={loadKey.current} | ||||
|         errMsg="Failed to load disk images list!" | ||||
|         load={load} | ||||
|         ready={list !== undefined} | ||||
|         build={() => ( | ||||
|           <VirtWebRouteContainer | ||||
|             label="Disk images management" | ||||
|             actions={ | ||||
|               <span> | ||||
|                 <Tooltip title="Refresh Disk images list"> | ||||
|                   <IconButton onClick={reload}> | ||||
|                     <RefreshIcon /> | ||||
|                   </IconButton> | ||||
|                 </Tooltip> | ||||
|               </span> | ||||
|             } | ||||
|           > | ||||
|           <> | ||||
|             <UploadDiskImageCard onFileUploaded={reload} /> | ||||
|             <DiskImageList list={list!} onReload={reload} /> | ||||
|           </VirtWebRouteContainer> | ||||
|           </> | ||||
|         )} | ||||
|       /> | ||||
|     </VirtWebRouteContainer> | ||||
| @@ -148,5 +159,175 @@ function DiskImageList(p: { | ||||
|   list: DiskImage[]; | ||||
|   onReload: () => void; | ||||
| }): React.ReactElement { | ||||
|   return <>todo</>; | ||||
|   const alert = useAlert(); | ||||
|   const snackbar = useSnackbar(); | ||||
|   const confirm = useConfirm(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [currConversion, setCurrConversion] = React.useState< | ||||
|     DiskImage | undefined | ||||
|   >(); | ||||
|   const [dlProgress, setDlProgress] = React.useState<undefined | number>(); | ||||
|  | ||||
|   // Convert disk image file | ||||
|   const convertDiskImage = (entry: DiskImage) => { | ||||
|     setCurrConversion(entry); | ||||
|   }; | ||||
|  | ||||
|   // Download disk image file | ||||
|   const downloadDiskImage = async (entry: DiskImage) => { | ||||
|     setDlProgress(0); | ||||
|  | ||||
|     try { | ||||
|       const blob = await DiskImageApi.Download(entry, setDlProgress); | ||||
|  | ||||
|       downloadBlob(blob, entry.file_name); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       alert(`Failed to download disk image file! ${e}`); | ||||
|     } | ||||
|  | ||||
|     setDlProgress(undefined); | ||||
|   }; | ||||
|  | ||||
|   // Delete disk image | ||||
|   const deleteDiskImage = async (entry: DiskImage) => { | ||||
|     if ( | ||||
|       !(await confirm( | ||||
|         `Do you really want to delete this disk image (${entry.file_name}) ?` | ||||
|       )) | ||||
|     ) | ||||
|       return; | ||||
|  | ||||
|     loadingMessage.show("Deleting disk image file..."); | ||||
|  | ||||
|     try { | ||||
|       await DiskImageApi.Delete(entry); | ||||
|       snackbar("The disk image has been successfully deleted!"); | ||||
|       p.onReload(); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       alert(`Failed to delete disk image!\n${e}`); | ||||
|     } | ||||
|  | ||||
|     loadingMessage.hide(); | ||||
|   }; | ||||
|  | ||||
|   if (p.list.length === 0) | ||||
|     return ( | ||||
|       <Typography variant="body1" style={{ textAlign: "center" }}> | ||||
|         No disk image uploaded for now. | ||||
|       </Typography> | ||||
|     ); | ||||
|  | ||||
|   const columns: GridColDef<(typeof p.list)[number]>[] = [ | ||||
|     { field: "file_name", headerName: "File name", flex: 3 }, | ||||
|     { | ||||
|       field: "format", | ||||
|       headerName: "Format", | ||||
|       flex: 1, | ||||
|       renderCell(params) { | ||||
|         let content = params.row.format; | ||||
|  | ||||
|         if (params.row.format === "Raw") { | ||||
|           content += params.row.is_sparse ? " (Sparse)" : " (Fixed)"; | ||||
|         } | ||||
|  | ||||
|         return content; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "file_size", | ||||
|       headerName: "File size", | ||||
|       flex: 1, | ||||
|       renderCell(params) { | ||||
|         let res = filesize(params.row.file_size); | ||||
|  | ||||
|         if (params.row.format === "QCow2") { | ||||
|           res += ` (${filesize(params.row.virtual_size!)})`; | ||||
|         } | ||||
|  | ||||
|         return res; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "created", | ||||
|       headerName: "Created", | ||||
|       flex: 1, | ||||
|       renderCell(params) { | ||||
|         return <DateWidget time={params.row.created} />; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "", | ||||
|       width: 140, | ||||
|       renderCell(params) { | ||||
|         return ( | ||||
|           <> | ||||
|             <Tooltip title="Convert disk image"> | ||||
|               <IconButton onClick={() => { convertDiskImage(params.row); }}> | ||||
|                 <LoopIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <Tooltip title="Download disk image"> | ||||
|               <IconButton onClick={() => downloadDiskImage(params.row)}> | ||||
|                 <DownloadIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <Tooltip title="Delete disk image"> | ||||
|               <IconButton onClick={() => deleteDiskImage(params.row)}> | ||||
|                 <DeleteIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|           </> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* Download notification */} | ||||
|       {dlProgress !== undefined && ( | ||||
|         <Alert severity="info"> | ||||
|           <div | ||||
|             style={{ | ||||
|               display: "flex", | ||||
|               flexDirection: "row", | ||||
|               alignItems: "center", | ||||
|               overflow: "hidden", | ||||
|             }} | ||||
|           > | ||||
|             <Typography variant="body1"> | ||||
|               Downloading... {dlProgress}% | ||||
|             </Typography> | ||||
|             <CircularProgress | ||||
|               variant="determinate" | ||||
|               size={"1.5rem"} | ||||
|               style={{ marginLeft: "10px" }} | ||||
|               value={dlProgress} | ||||
|             /> | ||||
|           </div> | ||||
|         </Alert> | ||||
|       )} | ||||
|  | ||||
|       {/* Disk image conversion dialog */} | ||||
|       {currConversion && ( | ||||
|         <ConvertDiskImageDialog | ||||
|           image={currConversion} | ||||
|           onCancel={() => { | ||||
|             setCurrConversion(undefined); | ||||
|           }} | ||||
|           onFinished={() => { | ||||
|             setCurrConversion(undefined); | ||||
|             p.onReload(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {/* The table itself */} | ||||
|       <DataGrid getRowId={(c) => c.file_name} rows={p.list} columns={columns} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										13
									
								
								virtweb_frontend/src/widgets/DateWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								virtweb_frontend/src/widgets/DateWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| export function DateWidget(p: { time: number }): React.ReactElement { | ||||
|   const date = new Date(p.time * 1000); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function pad(num: number): string { | ||||
|   return num.toString().padStart(2, "0"); | ||||
| } | ||||
							
								
								
									
										23
									
								
								virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material"; | ||||
| import { DiskImage } from "../api/DiskImageApi"; | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import { filesize } from "filesize"; | ||||
| import Icon from "@mdi/react"; | ||||
|  | ||||
| export function FileDiskImageWidget(p: { | ||||
|   image: DiskImage; | ||||
| }): React.ReactElement { | ||||
|   return ( | ||||
|     <ListItem> | ||||
|       <ListItemAvatar> | ||||
|         <Avatar> | ||||
|           <Icon path={mdiHarddisk} /> | ||||
|         </Avatar> | ||||
|       </ListItemAvatar> | ||||
|       <ListItemText | ||||
|         primary={p.image.file_name} | ||||
|         secondary={`${p.image.format} - ${filesize(p.image.file_size)}`} | ||||
|       /> | ||||
|     </ListItem> | ||||
|   ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user