Compare commits
	
		
			29 Commits
		
	
	
		
			20250531
			...
			5bc89bc847
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5bc89bc847 | |||
| 07d305c54e | |||
| 78c525f47a | |||
| 978a881372 | |||
| ee733b04f3 | |||
| 0d8ef226c1 | |||
| feca07558e | |||
| 0de15af10e | |||
| d4bc92f562 | |||
| a1439689dd | |||
| 63126c75fa | |||
| 940302a825 | |||
| 9c374f849b | |||
| 2fa4d0e11b | |||
| d7796e1459 | |||
| 759361d9f6 | |||
| b2529c250a | |||
| 8a7712ec42 | |||
| 9609cfb33a | |||
| 1fe7c60f36 | |||
| f1339f0711 | |||
| b3f56cea81 | |||
| 9bd702d60f | |||
| c8b42626a9 | |||
| 4ef15507d9 | |||
| 8a4b3a4db6 | |||
| 8bce9ca9b7 | |||
| 5574037b73 | |||
| d1ca9aee39 | 
							
								
								
									
										42
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										42
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -760,9 +760,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "clap" | ||||
| version = "4.5.38" | ||||
| version = "4.5.40" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" | ||||
| checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" | ||||
| dependencies = [ | ||||
|  "clap_builder", | ||||
|  "clap_derive", | ||||
| @@ -770,9 +770,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_builder" | ||||
| version = "4.5.38" | ||||
| version = "4.5.40" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" | ||||
| checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" | ||||
| dependencies = [ | ||||
|  "anstream", | ||||
|  "anstyle", | ||||
| @@ -782,9 +782,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_derive" | ||||
| version = "4.5.32" | ||||
| version = "4.5.40" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" | ||||
| checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" | ||||
| dependencies = [ | ||||
|  "heck", | ||||
|  "proc-macro2", | ||||
| @@ -1982,6 +1982,16 @@ dependencies = [ | ||||
|  "cc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "libyml" | ||||
| version = "0.0.5" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "version_check", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "light-openid" | ||||
| version = "1.0.4" | ||||
| @@ -3093,6 +3103,21 @@ dependencies = [ | ||||
|  "serde", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "serde_yml" | ||||
| version = "0.0.12" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" | ||||
| dependencies = [ | ||||
|  "indexmap", | ||||
|  "itoa", | ||||
|  "libyml", | ||||
|  "memchr", | ||||
|  "ryu", | ||||
|  "serde", | ||||
|  "version_check", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "sha1" | ||||
| version = "0.10.6" | ||||
| @@ -3413,9 +3438,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", | ||||
| @@ -3754,6 +3779,7 @@ dependencies = [ | ||||
|  "rust-embed", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "serde_yml", | ||||
|  "sysinfo", | ||||
|  "tempfile", | ||||
|  "thiserror 2.0.12", | ||||
|   | ||||
| @@ -8,7 +8,7 @@ edition = "2024" | ||||
| [dependencies] | ||||
| log = "0.4.27" | ||||
| env_logger = "0.11.8" | ||||
| clap = { version = "4.5.38", features = ["derive", "env"] } | ||||
| clap = { version = "4.5.40", features = ["derive", "env"] } | ||||
| light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | ||||
| lazy_static = "1.5.0" | ||||
| actix = "0.13.5" | ||||
| @@ -19,9 +19,10 @@ actix-identity = "0.8.0" | ||||
| actix-cors = "0.7.1" | ||||
| actix-files = "0.6.6" | ||||
| actix-ws = "0.3.0" | ||||
| actix-http = "3.10.0" | ||||
| actix-http = "3.11.0" | ||||
| serde = { version = "1.0.219", features = ["derive"] } | ||||
| serde_json = "1.0.140" | ||||
| serde_yml = "0.0.12" | ||||
| quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] } | ||||
| futures-util = "0.3.31" | ||||
| anyhow = "1.0.98" | ||||
| @@ -36,7 +37,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" | ||||
|   | ||||
| @@ -182,6 +182,13 @@ impl Handler<DeleteDomainReq> for LibVirtActor { | ||||
|             false => sys::VIR_DOMAIN_UNDEFINE_NVRAM, | ||||
|         })?; | ||||
|  | ||||
|         // Delete associated cloud init disk | ||||
|         let cloud_init_disk = AppConfig::get().cloud_init_disk_path_for_vm(&domain_name); | ||||
|         if cloud_init_disk.exists() { | ||||
|             std::fs::remove_file(cloud_init_disk)?; | ||||
|         } | ||||
|  | ||||
|         // If requested, delete block storage associated with the VM | ||||
|         if !msg.keep_files { | ||||
|             log::info!("Delete storage associated with the domain"); | ||||
|             let path = AppConfig::get().vm_storage_path(msg.id); | ||||
|   | ||||
| @@ -250,6 +250,19 @@ impl AppConfig { | ||||
|         self.storage_path().join("iso") | ||||
|     } | ||||
|  | ||||
|     /// Get the path where generated cloud init disk image are stored | ||||
|     pub fn cloud_init_disk_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("cloud_init_disks") | ||||
|     } | ||||
|  | ||||
|     /// Get the path where the disk image of a VM is stored | ||||
|     pub fn cloud_init_disk_path_for_vm(&self, name: &str) -> PathBuf { | ||||
|         self.cloud_init_disk_storage_path().join(format!( | ||||
|             "{}-{name}.iso", | ||||
|             constants::CLOUD_INIT_IMAGE_PREFIX_NAME | ||||
|         )) | ||||
|     } | ||||
|  | ||||
|     /// Get disk images storage directory | ||||
|     pub fn disk_images_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("disk_images") | ||||
|   | ||||
| @@ -30,8 +30,9 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ | ||||
| pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | ||||
|  | ||||
| /// Allowed uploaded disk images formats | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [ | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [ | ||||
|     "application/x-qemu-disk", | ||||
|     "application/x-raw-disk-image", | ||||
|     "application/gzip", | ||||
|     "application/octet-stream", | ||||
| ]; | ||||
| @@ -57,6 +58,9 @@ pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | ||||
| /// Disk size max (B) | ||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | ||||
|  | ||||
| /// Cloud init generated disk image prefix | ||||
| pub const CLOUD_INIT_IMAGE_PREFIX_NAME: &str = "virtweb-cloudinit-autogen-image"; | ||||
|  | ||||
| /// Net nat entry comment max size | ||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | ||||
|  | ||||
| @@ -122,19 +126,25 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30; | ||||
| pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; | ||||
|  | ||||
| /// Qemu image program path | ||||
| pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img"; | ||||
| pub const PROGRAM_QEMU_IMAGE: &str = "/usr/bin/qemu-img"; | ||||
|  | ||||
| /// IP program path | ||||
| pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | ||||
| pub const PROGRAM_IP: &str = "/usr/sbin/ip"; | ||||
|  | ||||
| /// Copy program path | ||||
| pub const COPY_PROGRAM: &str = "/bin/cp"; | ||||
| pub const PROGRAM_COPY: &str = "/bin/cp"; | ||||
|  | ||||
| /// Gzip program path | ||||
| pub const GZIP_PROGRAM: &str = "/usr/bin/gzip"; | ||||
| pub const PROGRAM_GZIP: &str = "/usr/bin/gzip"; | ||||
|  | ||||
| /// XZ program path | ||||
| pub const PROGRAM_XZ: &str = "/usr/bin/xz"; | ||||
|  | ||||
| /// Bash program | ||||
| pub const BASH_PROGRAM: &str = "/usr/bin/bash"; | ||||
| pub const PROGRAM_BASH: &str = "/usr/bin/bash"; | ||||
|  | ||||
| /// DD program | ||||
| pub const DD_PROGRAM: &str = "/usr/bin/dd"; | ||||
| pub const PROGRAM_DD: &str = "/usr/bin/dd"; | ||||
|  | ||||
| /// cloud-localds program | ||||
| pub const PROGRAM_CLOUD_LOCALDS: &str = "/usr/bin/cloud-localds"; | ||||
|   | ||||
| @@ -55,7 +55,15 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) | ||||
|     } | ||||
|  | ||||
|     // Copy the file to the destination | ||||
|     file.file.persist(dest_path)?; | ||||
|     file.file.persist(&dest_path)?; | ||||
|  | ||||
|     // Check if file information can be loaded | ||||
|     if let Err(e) = DiskFileInfo::load_file(&dest_path) { | ||||
|         log::error!("Failed to get information about uploaded disk file! {e}"); | ||||
|         std::fs::remove_file(&dest_path)?; | ||||
|         return Ok(HttpResponse::InternalServerError() | ||||
|             .json(format!("Unable to process uploaded file! {e}"))); | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json("Successfully uploaded disk image!")) | ||||
| } | ||||
|   | ||||
| @@ -109,6 +109,28 @@ pub async fn get_single_src_def(client: LibVirtReq, id: web::Path<SingleVMUUidRe | ||||
|         .body(info)) | ||||
| } | ||||
|  | ||||
| /// Get the generated cloud init configuration disk of a vm | ||||
| pub async fn get_cloud_init_disk(client: LibVirtReq, id: web::Path<SingleVMUUidReq>) -> HttpResult { | ||||
|     let info = match client.get_single_domain(id.uid).await { | ||||
|         Ok(i) => i, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to get domain information! {e}"); | ||||
|             return Ok(HttpResponse::InternalServerError().json(e.to_string())); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let vm = VMInfo::from_domain(info)?; | ||||
|     let disk = vm.cloud_init.generate_nocloud_disk()?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .content_type("application/x-iso9660-image") | ||||
|         .insert_header(( | ||||
|             "Content-Disposition", | ||||
|             format!("attachment; filename=\"cloud_init_{}.iso\"", vm.name), | ||||
|         )) | ||||
|         .body(disk)) | ||||
| } | ||||
|  | ||||
| /// Update a VM information | ||||
| pub async fn update( | ||||
|     client: LibVirtReq, | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::cloud_init_utils::CloudInitConfig; | ||||
|  | ||||
| /// VirtWeb specific metadata | ||||
| #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] | ||||
| @@ -8,6 +9,8 @@ pub struct DomainMetadataVirtWebXML { | ||||
|     pub ns: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub group: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub cloud_init: Option<CloudInitConfig>, | ||||
| } | ||||
|  | ||||
| /// Domain metadata | ||||
|   | ||||
| @@ -13,4 +13,6 @@ enum LibVirtStructError { | ||||
|     ParseFilteringChain(String), | ||||
|     #[error("NetworkFilterExtractionError: {0}")] | ||||
|     NetworkFilterExtraction(String), | ||||
|     #[error("CloudInitConfigurationError: {0}")] | ||||
|     CloudInitConfiguration(String), | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,10 @@ use crate::constants; | ||||
| 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::libvirt_rest_structures::LibVirtStructError::{ | ||||
|     CloudInitConfiguration, StructureExtraction, | ||||
| }; | ||||
| use crate::utils::cloud_init_utils::CloudInitConfig; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use crate::utils::files_utils; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | ||||
| @@ -94,6 +97,9 @@ pub struct VMInfo { | ||||
|     pub tpm_module: bool, | ||||
|     /// Strings injected as OEM Strings in SMBios configuration | ||||
|     pub oem_strings: Vec<String>, | ||||
|     /// Cloud init configuration | ||||
|     #[serde(default)] | ||||
|     pub cloud_init: CloudInitConfig, | ||||
| } | ||||
|  | ||||
| impl VMInfo { | ||||
| @@ -138,9 +144,26 @@ impl VMInfo { | ||||
|             return Err(StructureExtraction("Invalid number of vCPU specified!").into()); | ||||
|         } | ||||
|  | ||||
|         let mut disks = vec![]; | ||||
|         if let Some(e) = self.cloud_init.check_error() { | ||||
|             return Err(CloudInitConfiguration(e).into()); | ||||
|         } | ||||
|  | ||||
|         // Add ISO files | ||||
|         let mut iso_absolute_files = vec![]; | ||||
|  | ||||
|         // Process cloud init image | ||||
|         if self.cloud_init.attach_config { | ||||
|             let cloud_init_disk_path = AppConfig::get().cloud_init_disk_path_for_vm(&self.name); | ||||
|  | ||||
|             // Apply latest cloud init configuration | ||||
|             std::fs::write( | ||||
|                 &cloud_init_disk_path, | ||||
|                 self.cloud_init.generate_nocloud_disk()?, | ||||
|             )?; | ||||
|  | ||||
|             iso_absolute_files.push(cloud_init_disk_path); | ||||
|         } | ||||
|  | ||||
|         // Process uploaded 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()); | ||||
| @@ -152,6 +175,13 @@ impl VMInfo { | ||||
|                 return Err(StructureExtraction("Specified ISO file does not exists!").into()); | ||||
|             } | ||||
|  | ||||
|             iso_absolute_files.push(path); | ||||
|         } | ||||
|  | ||||
|         let mut disks = vec![]; | ||||
|  | ||||
|         // Add ISO disk files | ||||
|         for iso_path in iso_absolute_files { | ||||
|             disks.push(DiskXML { | ||||
|                 r#type: "file".to_string(), | ||||
|                 device: "cdrom".to_string(), | ||||
| @@ -161,7 +191,7 @@ impl VMInfo { | ||||
|                     cache: "none".to_string(), | ||||
|                 }, | ||||
|                 source: DiskSourceXML { | ||||
|                     file: path.to_string_lossy().to_string(), | ||||
|                     file: iso_path.to_string_lossy().to_string(), | ||||
|                 }, | ||||
|                 target: DiskTargetXML { | ||||
|                     dev: format!( | ||||
| @@ -178,6 +208,7 @@ impl VMInfo { | ||||
|             }) | ||||
|         } | ||||
|  | ||||
|         // Configure VNC access, if requested | ||||
|         let (vnc_graphics, vnc_video) = match self.vnc_access { | ||||
|             true => ( | ||||
|                 Some(GraphicsXML { | ||||
| @@ -340,6 +371,7 @@ impl VMInfo { | ||||
|                 virtweb: DomainMetadataVirtWebXML { | ||||
|                     ns: "https://virtweb.communiquons.org".to_string(), | ||||
|                     group: self.group.clone().map(|g| g.0), | ||||
|                     cloud_init: Some(self.cloud_init.clone()), | ||||
|                 }, | ||||
|             }), | ||||
|             os: OSXML { | ||||
| @@ -490,6 +522,7 @@ impl VMInfo { | ||||
|                 .iter() | ||||
|                 .filter(|d| d.device == "cdrom") | ||||
|                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) | ||||
|                 .filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME)) | ||||
|                 .collect(), | ||||
|  | ||||
|             file_disks: domain | ||||
| @@ -582,6 +615,13 @@ impl VMInfo { | ||||
|                 .and_then(|s| s.oem_strings) | ||||
|                 .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect()) | ||||
|                 .unwrap_or_default(), | ||||
|             cloud_init: domain | ||||
|                 .metadata | ||||
|                 .clone() | ||||
|                 .unwrap_or_default() | ||||
|                 .virtweb | ||||
|                 .cloud_init | ||||
|                 .unwrap_or_default(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -47,16 +47,22 @@ async fn main() -> std::io::Result<()> { | ||||
|  | ||||
|     log::debug!("Checking for required programs"); | ||||
|     exec_utils::check_program( | ||||
|         constants::QEMU_IMAGE_PROGRAM, | ||||
|         constants::PROGRAM_QEMU_IMAGE, | ||||
|         "QEMU disk image utility is required to manipulate QCow2 files!", | ||||
|     ); | ||||
|     exec_utils::check_program( | ||||
|         constants::IP_PROGRAM, | ||||
|         constants::PROGRAM_IP, | ||||
|         "ip is required to access bridges information!", | ||||
|     ); | ||||
|     exec_utils::check_program( | ||||
|         constants::PROGRAM_CLOUD_LOCALDS, | ||||
|         "cloud-localds from package cloud-image-utils is required to build cloud-init images!", | ||||
|     ); | ||||
|  | ||||
|     log::debug!("Create required directory, if missing"); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().cloud_init_disk_storage_path()) | ||||
|         .unwrap(); | ||||
|     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(); | ||||
| @@ -198,6 +204,10 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/vm/{uid}/src", | ||||
|                 web::get().to(vm_controller::get_single_src_def), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/vm/{uid}/cloud_init_disk", | ||||
|                 web::get().to(vm_controller::get_cloud_init_disk), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/vm/{uid}/autostart", | ||||
|                 web::get().to(vm_controller::get_autostart), | ||||
|   | ||||
							
								
								
									
										117
									
								
								virtweb_backend/src/utils/cloud_init_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								virtweb_backend/src/utils/cloud_init_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use std::process::Command; | ||||
|  | ||||
| /// Cloud init DS Mode | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] | ||||
| pub enum CloudInitDSMode { | ||||
|     /// Networking is required | ||||
|     Net, | ||||
|     /// Does not require networking to be up before user-data actions are run | ||||
|     Local, | ||||
| } | ||||
|  | ||||
| /// VM Cloud Init configuration | ||||
| /// | ||||
| /// RedHat documentation: https://docs.redhat.com/fr/documentation/red_hat_enterprise_linux/9/html/configuring_and_managing_cloud-init_for_rhel_9/configuring-cloud-init_cloud-content | ||||
| /// cloud-localds source code: https://github.com/canonical/cloud-utils/blob/main/bin/cloud-localds | ||||
| #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)] | ||||
| pub struct CloudInitConfig { | ||||
|     pub attach_config: bool, | ||||
|     /// Main user data | ||||
|     pub user_data: String, | ||||
|     /// Instance ID, set in metadata file | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub instance_id: Option<String>, | ||||
|     /// Local hostname, set in metadata file | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub local_hostname: Option<String>, | ||||
|     /// Data source mode | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub dsmode: Option<CloudInitDSMode>, | ||||
|     /// Network configuration | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub network_configuration: Option<String>, | ||||
| } | ||||
|  | ||||
| impl CloudInitConfig { | ||||
|     /// Check cloud init configuration | ||||
|     pub fn check_error(&self) -> Option<String> { | ||||
|         if !self.user_data.is_empty() { | ||||
|             // Check YAML content | ||||
|             if let Err(e) = serde_yml::from_str::<serde_json::Value>(&self.user_data) { | ||||
|                 return Some(format!( | ||||
|                     "user data is an invalid YAML file! Deserialization error: {e}" | ||||
|                 )); | ||||
|             } | ||||
|  | ||||
|             // Check first line | ||||
|             if !self.user_data.starts_with("#cloud-config\n") { | ||||
|                 return Some( | ||||
|                     "user data file MUST start with '#cloud-config' as first line!".to_string(), | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         None | ||||
|     } | ||||
|  | ||||
|     /// Generate disk image for nocloud usage | ||||
|     pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> { | ||||
|         let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; | ||||
|  | ||||
|         let mut cmd = Command::new(constants::PROGRAM_CLOUD_LOCALDS); | ||||
|  | ||||
|         // ISO destination path | ||||
|         let temp_iso = temp_path.path().join("disk.iso"); | ||||
|         cmd.arg(&temp_iso); | ||||
|  | ||||
|         // Process network configuration | ||||
|         if let Some(net_conf) = &self.network_configuration { | ||||
|             let net_conf_path = temp_path.path().join("network"); | ||||
|             std::fs::write(&net_conf_path, net_conf)?; | ||||
|             cmd.arg("--network-config").arg(&net_conf_path); | ||||
|         } | ||||
|  | ||||
|         // Process user data | ||||
|         let user_data_path = temp_path.path().join("user-data"); | ||||
|         std::fs::write(&user_data_path, &self.user_data)?; | ||||
|         cmd.arg(user_data_path); | ||||
|  | ||||
|         // Process metadata | ||||
|         let mut metadatas = vec![]; | ||||
|         if let Some(inst_id) = &self.instance_id { | ||||
|             metadatas.push(format!("instance-id: {}", inst_id)); | ||||
|         } | ||||
|         if let Some(local_hostname) = &self.local_hostname { | ||||
|             metadatas.push(format!("local-hostname: {}", local_hostname)); | ||||
|         } | ||||
|         if let Some(dsmode) = &self.dsmode { | ||||
|             metadatas.push(format!( | ||||
|                 "dsmode: {}", | ||||
|                 match dsmode { | ||||
|                     CloudInitDSMode::Net => "net", | ||||
|                     CloudInitDSMode::Local => "local", | ||||
|                 } | ||||
|             )); | ||||
|         } | ||||
|         let meta_data_path = temp_path.path().join("meta-data"); | ||||
|         std::fs::write(&meta_data_path, metadatas.join("\n"))?; | ||||
|         cmd.arg(meta_data_path); | ||||
|  | ||||
|         // Execute command | ||||
|         let output = cmd.output()?; | ||||
|         if !output.status.success() { | ||||
|             anyhow::bail!( | ||||
|                 "{} exited with status {}!\nStdout: {}\nStderr: {}", | ||||
|                 constants::PROGRAM_CLOUD_LOCALDS, | ||||
|                 output.status, | ||||
|                 String::from_utf8_lossy(&output.stdout), | ||||
|                 String::from_utf8_lossy(&output.stderr) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         // Read generated ISO file | ||||
|         Ok(std::fs::read(temp_iso)?) | ||||
|     } | ||||
| } | ||||
| @@ -28,8 +28,10 @@ pub enum DiskFileFormat { | ||||
|         #[serde(default)] | ||||
|         virtual_size: FileSize, | ||||
|     }, | ||||
|     CompressedRaw, | ||||
|     CompressedQCow2, | ||||
|     GzCompressedRaw, | ||||
|     GzCompressedQCow2, | ||||
|     XzCompressedRaw, | ||||
|     XzCompressedQCow2, | ||||
| } | ||||
|  | ||||
| impl DiskFileFormat { | ||||
| @@ -37,8 +39,10 @@ impl DiskFileFormat { | ||||
|         match self { | ||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||
|             DiskFileFormat::CompressedRaw => &["raw.gz"], | ||||
|             DiskFileFormat::CompressedQCow2 => &["qcow2.gz"], | ||||
|             DiskFileFormat::GzCompressedRaw => &["raw.gz"], | ||||
|             DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"], | ||||
|             DiskFileFormat::XzCompressedRaw => &["raw.xz"], | ||||
|             DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"], | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -81,9 +85,14 @@ impl DiskFileInfo { | ||||
|             }, | ||||
|             "gz" if name.ends_with(".qcow2") => { | ||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); | ||||
|                 DiskFileFormat::CompressedQCow2 | ||||
|                 DiskFileFormat::GzCompressedQCow2 | ||||
|             } | ||||
|             "gz" => DiskFileFormat::CompressedRaw, | ||||
|             "gz" => DiskFileFormat::GzCompressedRaw, | ||||
|             "xz" if name.ends_with(".qcow2") => { | ||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); | ||||
|                 DiskFileFormat::XzCompressedQCow2 | ||||
|             } | ||||
|             "xz" => DiskFileFormat::XzCompressedRaw, | ||||
|             _ => anyhow::bail!("Unsupported disk extension: {ext}!"), | ||||
|         }; | ||||
|  | ||||
| @@ -124,7 +133,7 @@ impl DiskFileInfo { | ||||
|             } | ||||
|  | ||||
|             DiskFileFormat::QCow2 { virtual_size } => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|                 cmd.arg("create") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
| @@ -159,9 +168,9 @@ impl DiskFileInfo { | ||||
|  | ||||
|         // 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); | ||||
|             // Decompress QCow2 (GZIP) | ||||
|             (DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
| @@ -170,9 +179,30 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|             // Decompress QCow2 (XZ) | ||||
|             (DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--stdout") | ||||
|                     .arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 (Gzip) | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 (Xz) | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
| @@ -182,8 +212,14 @@ impl DiskFileInfo { | ||||
|  | ||||
|             // 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); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg("-O") | ||||
|                     .arg("raw") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|  | ||||
|                 if !is_sparse { | ||||
|                     cmd.args(["-S", "0"]); | ||||
| @@ -195,8 +231,10 @@ impl DiskFileInfo { | ||||
|             // 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); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg("-O") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(&self.file_path) | ||||
| @@ -206,15 +244,21 @@ impl DiskFileInfo { | ||||
|  | ||||
|             // 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); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-f") | ||||
|                     .arg("raw") | ||||
|                     .arg("-O") | ||||
|                     .arg("qcow2") | ||||
|                     .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); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_COPY); | ||||
|                 cmd.arg("--sparse=never") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
| @@ -223,16 +267,16 @@ impl DiskFileInfo { | ||||
|  | ||||
|             // Render raw file sparse | ||||
|             (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::DD_PROGRAM); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_DD); | ||||
|                 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); | ||||
|             // Compress Raw (Gz) | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
| @@ -240,9 +284,29 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to not sparse file | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|             // Compress Raw (Xz) | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw (Gz) to not sparse file | ||||
|             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|             // Decompress Raw (Xz) to not sparse file | ||||
|             (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
| @@ -251,15 +315,29 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to sparse file | ||||
|             // Decompress Raw (Gz) 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); | ||||
|             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_BASH); | ||||
|                 cmd.arg("-c").arg(format!( | ||||
|                     "{} -d -c {} | {} conv=sparse of={}", | ||||
|                     constants::GZIP_PROGRAM, | ||||
|                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", | ||||
|                     constants::PROGRAM_GZIP, | ||||
|                     self.file_path.display(), | ||||
|                     constants::DD_PROGRAM, | ||||
|                     constants::PROGRAM_DD, | ||||
|                     temp_file.display() | ||||
|                 )); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw (XZ) to sparse file | ||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||
|             (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_BASH); | ||||
|                 cmd.arg("-c").arg(format!( | ||||
|                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", | ||||
|                     constants::PROGRAM_XZ, | ||||
|                     self.file_path.display(), | ||||
|                     constants::PROGRAM_DD, | ||||
|                     temp_file.display() | ||||
|                 )); | ||||
|                 cmd | ||||
| @@ -267,7 +345,7 @@ impl DiskFileInfo { | ||||
|  | ||||
|             // Dumb copy of file | ||||
|             (a, b) if a == b => { | ||||
|                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_COPY); | ||||
|                 cmd.arg("--sparse=auto") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
| @@ -316,6 +394,44 @@ impl DiskFileInfo { | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Get disk virtual size, if available | ||||
|     pub fn virtual_size(&self) -> Option<FileSize> { | ||||
|         match self.format { | ||||
|             DiskFileFormat::Raw { .. } => Some(self.file_size), | ||||
|             DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Resize disk | ||||
|     pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> { | ||||
|         if new_size <= self.virtual_size().unwrap_or(new_size) { | ||||
|             anyhow::bail!("Shrinking disk image file is not supported!"); | ||||
|         } | ||||
|  | ||||
|         let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|         cmd.arg("resize") | ||||
|             .arg("-f") | ||||
|             .arg(match self.format { | ||||
|                 DiskFileFormat::QCow2 { .. } => "qcow2", | ||||
|                 DiskFileFormat::Raw { .. } => "raw", | ||||
|                 f => anyhow::bail!("Unsupported disk format for resize: {f:?}"), | ||||
|             }) | ||||
|             .arg(&self.file_path) | ||||
|             .arg(new_size.as_bytes().to_string()); | ||||
|  | ||||
|         let output = cmd.output()?; | ||||
|         if !output.status.success() { | ||||
|             anyhow::bail!( | ||||
|                 "{} info failed, status: {}, stderr: {}", | ||||
|                 constants::PROGRAM_QEMU_IMAGE, | ||||
|                 output.status, | ||||
|                 String::from_utf8_lossy(&output.stderr) | ||||
|             ); | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| @@ -327,7 +443,7 @@ struct QCowInfoOutput { | ||||
| /// Get QCow2 virtual size | ||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
|     // Run qemu-img | ||||
|     let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|     let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|     cmd.args([ | ||||
|         "info", | ||||
|         path.to_str().unwrap_or(""), | ||||
| @@ -339,7 +455,7 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
|     if !output.status.success() { | ||||
|         anyhow::bail!( | ||||
|             "{} info failed, status: {}, stderr: {}", | ||||
|             constants::QEMU_IMAGE_PROGRAM, | ||||
|             constants::PROGRAM_QEMU_IMAGE, | ||||
|             output.status, | ||||
|             String::from_utf8_lossy(&output.stderr) | ||||
|         ); | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| pub mod cloud_init_utils; | ||||
| pub mod exec_utils; | ||||
| pub mod file_disks_utils; | ||||
| pub mod file_size_utils; | ||||
|   | ||||
| @@ -145,13 +145,13 @@ struct IPBridgeInfo { | ||||
|  | ||||
| /// Get the list of bridge interfaces | ||||
| pub fn bridges_list() -> anyhow::Result<Vec<String>> { | ||||
|     let mut cmd = Command::new(constants::IP_PROGRAM); | ||||
|     let mut cmd = Command::new(constants::PROGRAM_IP); | ||||
|     cmd.args(["-json", "link", "show", "type", "bridge"]); | ||||
|     let output = cmd.output()?; | ||||
|     if !output.status.success() { | ||||
|         anyhow::bail!( | ||||
|             "{} failed, status: {}, stderr: {}", | ||||
|             constants::IP_PROGRAM, | ||||
|             constants::PROGRAM_IP, | ||||
|             output.status, | ||||
|             String::from_utf8_lossy(&output.stderr) | ||||
|         ); | ||||
|   | ||||
| @@ -44,6 +44,9 @@ pub struct VMFileDisk { | ||||
|     /// When creating a new disk, specify the disk image template to use | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub from_image: Option<String>, | ||||
|     /// Set this variable to true to resize disk image | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub resize: Option<bool>, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
| @@ -78,6 +81,7 @@ impl VMFileDisk { | ||||
|  | ||||
|             delete: false, | ||||
|             from_image: None, | ||||
|             resize: None, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
| @@ -144,28 +148,40 @@ impl VMFileDisk { | ||||
|  | ||||
|         if file.exists() { | ||||
|             log::debug!("File {file:?} does not exists, so it was not touched"); | ||||
|             return Ok(()); | ||||
|         } | ||||
|         // Create disk if required | ||||
|         else { | ||||
|             // Determine file format | ||||
|             let format = match self.format { | ||||
|                 VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, | ||||
|                 VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                     virtual_size: self.size, | ||||
|                 }, | ||||
|             }; | ||||
|  | ||||
|             // Create / Restore disk file | ||||
|             match &self.from_image { | ||||
|                 // Create disk file | ||||
|                 None => { | ||||
|                     DiskFileInfo::create(&file, format, self.size)?; | ||||
|                 } | ||||
|  | ||||
|                 // Restore disk image template | ||||
|                 Some(disk_img) => { | ||||
|                     let src_file = | ||||
|                         DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?; | ||||
|                     src_file.convert(&file, format)?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let format = match self.format { | ||||
|             VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, | ||||
|             VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                 virtual_size: self.size, | ||||
|             }, | ||||
|         }; | ||||
|         // Resize disk file if requested | ||||
|         if self.resize == Some(true) { | ||||
|             let disk = DiskFileInfo::load_file(&file)?; | ||||
|  | ||||
|         // Create / Restore disk file | ||||
|         match &self.from_image { | ||||
|             // Create disk file | ||||
|             None => { | ||||
|                 DiskFileInfo::create(&file, format, self.size)?; | ||||
|             } | ||||
|  | ||||
|             // Restore disk image template | ||||
|             Some(disk_img) => { | ||||
|                 let src_file = | ||||
|                     DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?; | ||||
|                 src_file.convert(&file, format)?; | ||||
|             // Can only increase disk size | ||||
|             if let Err(e) = disk.resize(self.size) { | ||||
|                 log::error!("Failed to resize disk file {}: {e:?}", self.name); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -5,9 +5,9 @@ | ||||
| sudo apt install libvirt-dev | ||||
| ``` | ||||
|  | ||||
| 2. Libvirt must also be installed: | ||||
| 2. Libvirt and cloud image utilities must also be installed: | ||||
| ```bash | ||||
| sudo apt install qemu-kvm libvirt-daemon-system | ||||
| sudo apt install qemu-kvm libvirt-daemon-system cloud-image-utils | ||||
| ``` | ||||
|  | ||||
| 3. Allow the current user to manage VMs: | ||||
|   | ||||
| @@ -12,10 +12,10 @@ The release file will be available in `virtweb_backend/target/release/virtweb_ba | ||||
| This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory. | ||||
|  | ||||
| ## Install requirements | ||||
| In order to work properly, VirtWeb relies on `libvirt`, `qemu` and `kvm`: | ||||
| In order to work properly, VirtWeb relies on `libvirt`, `qemu`, `kvm` and `cloud-localds`: | ||||
|  | ||||
| ```bash | ||||
| sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils | ||||
| sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils cloud-image-utils | ||||
| ``` | ||||
|  | ||||
| ## Dedicated user | ||||
|   | ||||
							
								
								
									
										287
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										287
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -13,16 +13,19 @@ | ||||
|         "@fontsource/roboto": "^5.2.5", | ||||
|         "@mdi/js": "^7.4.47", | ||||
|         "@mdi/react": "^1.6.1", | ||||
|         "@mui/icons-material": "^7.1.0", | ||||
|         "@mui/material": "^7.1.0", | ||||
|         "@monaco-editor/react": "^4.7.0", | ||||
|         "@mui/icons-material": "^7.1.1", | ||||
|         "@mui/material": "^7.1.1", | ||||
|         "@mui/x-charts": "^8.3.1", | ||||
|         "@mui/x-data-grid": "^8.3.1", | ||||
|         "date-and-time": "^3.6.0", | ||||
|         "filesize": "^10.1.6", | ||||
|         "humanize-duration": "^3.32.2", | ||||
|         "monaco-editor": "^0.52.2", | ||||
|         "monaco-yaml": "^5.4.0", | ||||
|         "react": "^19.1.0", | ||||
|         "react-dom": "^19.1.0", | ||||
|         "react-router-dom": "^7.6.0", | ||||
|         "react-router-dom": "^7.6.2", | ||||
|         "react-syntax-highlighter": "^15.6.1", | ||||
|         "react-vnc": "^3.1.0", | ||||
|         "uuid": "^11.1.0", | ||||
| @@ -32,14 +35,14 @@ | ||||
|         "@eslint/js": "^9.27.0", | ||||
|         "@types/humanize-duration": "^3.27.4", | ||||
|         "@types/jest": "^29.5.14", | ||||
|         "@types/react": "^19.1.5", | ||||
|         "@types/react-dom": "^19.1.5", | ||||
|         "@types/react": "^19.1.6", | ||||
|         "@types/react-dom": "^19.1.6", | ||||
|         "@types/react-syntax-highlighter": "^15.5.13", | ||||
|         "@types/uuid": "^10.0.0", | ||||
|         "@vitejs/plugin-react": "^4.4.1", | ||||
|         "eslint": "^9.27.0", | ||||
|         "eslint-plugin-react-dom": "^1.49.0", | ||||
|         "eslint-plugin-react-hooks": "^5.1.0", | ||||
|         "eslint-plugin-react-hooks": "^5.2.0", | ||||
|         "eslint-plugin-react-refresh": "^0.4.20", | ||||
|         "eslint-plugin-react-x": "^1.49.0", | ||||
|         "globals": "^16.1.0", | ||||
| @@ -958,10 +961,33 @@ | ||||
|         "prop-types": "^15.7.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@monaco-editor/loader": { | ||||
|       "version": "1.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", | ||||
|       "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "state-local": "^1.0.6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@monaco-editor/react": { | ||||
|       "version": "4.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", | ||||
|       "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@monaco-editor/loader": "^1.5.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "monaco-editor": ">= 0.25.0 < 1", | ||||
|         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/core-downloads-tracker": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.0.tgz", | ||||
|       "integrity": "sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz", | ||||
|       "integrity": "sha512-yBckQs4aQ8mqukLnPC6ivIRv6guhaXi8snVl00VtyojBbm+l6VbVhyTSZ68Abcx7Ah8B+GZhrB7BOli+e+9LkQ==", | ||||
|       "license": "MIT", | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
| @@ -969,9 +995,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/icons-material": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz", | ||||
|       "integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.1.tgz", | ||||
|       "integrity": "sha512-X37+Yc8QpEnl0sYmz+WcLFy2dWgNRzbswDzLPXG7QU1XDVlP5TPp1HXjdmCupOWLL/I9m1fyhcyZl8/HPpp/Cg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1" | ||||
| @@ -984,7 +1010,7 @@ | ||||
|         "url": "https://opencollective.com/mui-org" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@mui/material": "^7.1.0", | ||||
|         "@mui/material": "^7.1.1", | ||||
|         "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       }, | ||||
| @@ -995,16 +1021,16 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/material": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz", | ||||
|       "integrity": "sha512-ahUJdrhEv+mCp4XHW+tHIEYzZMSRLg8z4AjUOsj44QpD1ZaMxQoVOG2xiHvLFdcsIPbgSRx1bg1eQSheHBgvtg==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz", | ||||
|       "integrity": "sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/core-downloads-tracker": "^7.1.0", | ||||
|         "@mui/system": "^7.1.0", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@mui/utils": "^7.1.0", | ||||
|         "@mui/core-downloads-tracker": "^7.1.1", | ||||
|         "@mui/system": "^7.1.1", | ||||
|         "@mui/types": "^7.4.3", | ||||
|         "@mui/utils": "^7.1.1", | ||||
|         "@popperjs/core": "^2.11.8", | ||||
|         "@types/react-transition-group": "^4.4.12", | ||||
|         "clsx": "^2.1.1", | ||||
| @@ -1023,7 +1049,7 @@ | ||||
|       "peerDependencies": { | ||||
|         "@emotion/react": "^11.5.0", | ||||
|         "@emotion/styled": "^11.3.0", | ||||
|         "@mui/material-pigment-css": "^7.1.0", | ||||
|         "@mui/material-pigment-css": "^7.1.1", | ||||
|         "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
| @@ -1044,13 +1070,13 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/private-theming": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.0.tgz", | ||||
|       "integrity": "sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz", | ||||
|       "integrity": "sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/utils": "^7.1.0", | ||||
|         "@mui/utils": "^7.1.1", | ||||
|         "prop-types": "^15.8.1" | ||||
|       }, | ||||
|       "engines": { | ||||
| @@ -1071,9 +1097,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/styled-engine": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.0.tgz", | ||||
|       "integrity": "sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.1.tgz", | ||||
|       "integrity": "sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
| @@ -1105,16 +1131,16 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/system": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.0.tgz", | ||||
|       "integrity": "sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz", | ||||
|       "integrity": "sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/private-theming": "^7.1.0", | ||||
|         "@mui/styled-engine": "^7.1.0", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@mui/utils": "^7.1.0", | ||||
|         "@mui/private-theming": "^7.1.1", | ||||
|         "@mui/styled-engine": "^7.1.1", | ||||
|         "@mui/types": "^7.4.3", | ||||
|         "@mui/utils": "^7.1.1", | ||||
|         "clsx": "^2.1.1", | ||||
|         "csstype": "^3.1.3", | ||||
|         "prop-types": "^15.8.1" | ||||
| @@ -1145,9 +1171,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/types": { | ||||
|       "version": "7.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.2.tgz", | ||||
|       "integrity": "sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==", | ||||
|       "version": "7.4.3", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz", | ||||
|       "integrity": "sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1" | ||||
| @@ -1162,13 +1188,13 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/utils": { | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", | ||||
|       "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz", | ||||
|       "integrity": "sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@mui/types": "^7.4.3", | ||||
|         "@types/prop-types": "^15.7.14", | ||||
|         "clsx": "^2.1.1", | ||||
|         "prop-types": "^15.8.1", | ||||
| @@ -1598,18 +1624,18 @@ | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/react": { | ||||
|       "version": "19.1.5", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz", | ||||
|       "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==", | ||||
|       "version": "19.1.6", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", | ||||
|       "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "csstype": "^3.0.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/react-dom": { | ||||
|       "version": "19.1.5", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", | ||||
|       "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", | ||||
|       "version": "19.1.6", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", | ||||
|       "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", | ||||
|       "dev": true, | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
| @@ -3481,6 +3507,12 @@ | ||||
|         "node": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jsonc-parser": { | ||||
|       "version": "3.3.1", | ||||
|       "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", | ||||
|       "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/keyv": { | ||||
|       "version": "4.5.4", | ||||
|       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", | ||||
| @@ -3607,6 +3639,84 @@ | ||||
|         "node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-editor": { | ||||
|       "version": "0.52.2", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", | ||||
|       "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/monaco-languageserver-types": { | ||||
|       "version": "0.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz", | ||||
|       "integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "monaco-types": "^0.1.0", | ||||
|         "vscode-languageserver-protocol": "^3.0.0", | ||||
|         "vscode-uri": "^3.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-marker-data-provider": { | ||||
|       "version": "1.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.4.tgz", | ||||
|       "integrity": "sha512-4DsPgsAqpTyUDs3humXRBPUJoihTv+L6v9aupQWD80X2YXaCXUd11mWYeSCYHuPgdUmjFaNWCEOjQ6ewf/QA1Q==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "monaco-types": "^0.1.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-types": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz", | ||||
|       "integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==", | ||||
|       "license": "MIT", | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-worker-manager": { | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz", | ||||
|       "integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==", | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
|         "monaco-editor": ">=0.30.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-yaml": { | ||||
|       "version": "5.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.0.tgz", | ||||
|       "integrity": "sha512-tuBVDy1KAPrgO905GHTItu8AaA5bIzF5S4X0JVRAE/D66FpRhkDUk7tKi5bwKMVTTugtpMLsXN4ewh4CgE/FtQ==", | ||||
|       "license": "MIT", | ||||
|       "workspaces": [ | ||||
|         "examples/*" | ||||
|       ], | ||||
|       "dependencies": { | ||||
|         "jsonc-parser": "^3.0.0", | ||||
|         "monaco-languageserver-types": "^0.4.0", | ||||
|         "monaco-marker-data-provider": "^1.0.0", | ||||
|         "monaco-types": "^0.1.0", | ||||
|         "monaco-worker-manager": "^2.0.0", | ||||
|         "path-browserify": "^1.0.0", | ||||
|         "prettier": "^3.0.0", | ||||
|         "vscode-languageserver-textdocument": "^1.0.0", | ||||
|         "vscode-languageserver-types": "^3.0.0", | ||||
|         "vscode-uri": "^3.0.0", | ||||
|         "yaml": "^2.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "monaco-editor": ">=0.36" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/ms": { | ||||
|       "version": "2.1.3", | ||||
|       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | ||||
| @@ -3753,6 +3863,12 @@ | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/path-browserify": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", | ||||
|       "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/path-exists": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", | ||||
| @@ -3846,6 +3962,21 @@ | ||||
|         "node": ">= 0.8.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/prettier": { | ||||
|       "version": "3.5.3", | ||||
|       "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", | ||||
|       "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", | ||||
|       "license": "MIT", | ||||
|       "bin": { | ||||
|         "prettier": "bin/prettier.cjs" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/prettier/prettier?sponsor=1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/pretty-format": { | ||||
|       "version": "29.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", | ||||
| @@ -3989,9 +4120,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-router": { | ||||
|       "version": "7.6.0", | ||||
|       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", | ||||
|       "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", | ||||
|       "version": "7.6.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", | ||||
|       "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "cookie": "^1.0.1", | ||||
| @@ -4011,12 +4142,12 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-router-dom": { | ||||
|       "version": "7.6.0", | ||||
|       "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", | ||||
|       "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", | ||||
|       "version": "7.6.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", | ||||
|       "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "react-router": "7.6.0" | ||||
|         "react-router": "7.6.2" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=20.0.0" | ||||
| @@ -4326,6 +4457,12 @@ | ||||
|         "node": ">=8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/state-local": { | ||||
|       "version": "1.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", | ||||
|       "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/string-ts": { | ||||
|       "version": "2.2.1", | ||||
|       "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.2.1.tgz", | ||||
| @@ -4714,6 +4851,43 @@ | ||||
|         "url": "https://github.com/sponsors/jonschlinkert" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/vscode-jsonrpc": { | ||||
|       "version": "8.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", | ||||
|       "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/vscode-languageserver-protocol": { | ||||
|       "version": "3.17.5", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", | ||||
|       "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "vscode-jsonrpc": "8.2.0", | ||||
|         "vscode-languageserver-types": "3.17.5" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/vscode-languageserver-textdocument": { | ||||
|       "version": "1.0.12", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", | ||||
|       "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/vscode-languageserver-types": { | ||||
|       "version": "3.17.5", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", | ||||
|       "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/vscode-uri": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", | ||||
|       "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/which": { | ||||
|       "version": "2.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", | ||||
| @@ -4781,10 +4955,7 @@ | ||||
|       "version": "2.8.0", | ||||
|       "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", | ||||
|       "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", | ||||
|       "dev": true, | ||||
|       "license": "ISC", | ||||
|       "optional": true, | ||||
|       "peer": true, | ||||
|       "bin": { | ||||
|         "yaml": "bin.mjs" | ||||
|       }, | ||||
|   | ||||
| @@ -15,16 +15,19 @@ | ||||
|     "@fontsource/roboto": "^5.2.5", | ||||
|     "@mdi/js": "^7.4.47", | ||||
|     "@mdi/react": "^1.6.1", | ||||
|     "@mui/icons-material": "^7.1.0", | ||||
|     "@mui/material": "^7.1.0", | ||||
|     "@monaco-editor/react": "^4.7.0", | ||||
|     "@mui/icons-material": "^7.1.1", | ||||
|     "@mui/material": "^7.1.1", | ||||
|     "@mui/x-charts": "^8.3.1", | ||||
|     "@mui/x-data-grid": "^8.3.1", | ||||
|     "date-and-time": "^3.6.0", | ||||
|     "filesize": "^10.1.6", | ||||
|     "humanize-duration": "^3.32.2", | ||||
|     "monaco-editor": "^0.52.2", | ||||
|     "monaco-yaml": "^5.4.0", | ||||
|     "react": "^19.1.0", | ||||
|     "react-dom": "^19.1.0", | ||||
|     "react-router-dom": "^7.6.0", | ||||
|     "react-router-dom": "^7.6.2", | ||||
|     "react-syntax-highlighter": "^15.6.1", | ||||
|     "react-vnc": "^3.1.0", | ||||
|     "uuid": "^11.1.0", | ||||
| @@ -34,14 +37,14 @@ | ||||
|     "@eslint/js": "^9.27.0", | ||||
|     "@types/humanize-duration": "^3.27.4", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/react": "^19.1.5", | ||||
|     "@types/react-dom": "^19.1.5", | ||||
|     "@types/react": "^19.1.6", | ||||
|     "@types/react-dom": "^19.1.6", | ||||
|     "@types/react-syntax-highlighter": "^15.5.13", | ||||
|     "@types/uuid": "^10.0.0", | ||||
|     "@vitejs/plugin-react": "^4.4.1", | ||||
|     "eslint": "^9.27.0", | ||||
|     "eslint-plugin-react-dom": "^1.49.0", | ||||
|     "eslint-plugin-react-hooks": "^5.1.0", | ||||
|     "eslint-plugin-react-hooks": "^5.2.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.20", | ||||
|     "eslint-plugin-react-x": "^1.49.0", | ||||
|     "globals": "^16.1.0", | ||||
|   | ||||
| @@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi"; | ||||
| export type DiskImageFormat = | ||||
|   | { format: "Raw"; is_sparse: boolean } | ||||
|   | { format: "QCow2"; virtual_size?: number } | ||||
|   | { format: "CompressedQCow2" } | ||||
|   | { format: "CompressedRaw" }; | ||||
|   | { format: "GzCompressedQCow2" } | ||||
|   | { format: "GzCompressedRaw" } | ||||
|   | { format: "XzCompressedQCow2" } | ||||
|   | { format: "XzCompressedRaw" }; | ||||
|  | ||||
| export type DiskImage = { | ||||
|   file_size: number; | ||||
|   | ||||
| @@ -31,8 +31,12 @@ export interface BaseFileVMDisk { | ||||
|   // For new disk only | ||||
|   from_image?: string; | ||||
|  | ||||
|   // Resize disk image after clone | ||||
|   resize?: boolean; | ||||
|  | ||||
|   // application attributes | ||||
|   new?: boolean; | ||||
|   originalSize?: number; | ||||
|   deleteType?: "keepfile" | "deletefile"; | ||||
| } | ||||
|  | ||||
| @@ -82,6 +86,15 @@ export interface VMNetBridge { | ||||
|   bridge: string; | ||||
| } | ||||
|  | ||||
| export interface VMCloudInit { | ||||
|   attach_config: boolean; | ||||
|   user_data: string; | ||||
|   instance_id?: string; | ||||
|   local_hostname?: string; | ||||
|   dsmode?: "Net" | "Local"; | ||||
|   network_configuration?: string; | ||||
| } | ||||
|  | ||||
| export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||
|  | ||||
| interface VMInfoInterface { | ||||
| @@ -101,6 +114,7 @@ interface VMInfoInterface { | ||||
|   networks: VMNetInterface[]; | ||||
|   tpm_module: boolean; | ||||
|   oem_strings: string[]; | ||||
|   cloud_init: VMCloudInit; | ||||
| } | ||||
|  | ||||
| export class VMInfo implements VMInfoInterface { | ||||
| @@ -120,6 +134,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|   networks: VMNetInterface[]; | ||||
|   tpm_module: boolean; | ||||
|   oem_strings: string[]; | ||||
|   cloud_init: VMCloudInit; | ||||
|  | ||||
|   constructor(int: VMInfoInterface) { | ||||
|     this.name = int.name; | ||||
| @@ -138,6 +153,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|     this.networks = int.networks; | ||||
|     this.tpm_module = int.tpm_module; | ||||
|     this.oem_strings = int.oem_strings; | ||||
|     this.cloud_init = int.cloud_init; | ||||
|   } | ||||
|  | ||||
|   static NewEmpty(): VMInfo { | ||||
| @@ -153,6 +169,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|       networks: [], | ||||
|       tpm_module: true, | ||||
|       oem_strings: [], | ||||
|       cloud_init: { attach_config: false, user_data: "" }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -42,13 +42,15 @@ export function ConvertDiskImageDialog( | ||||
|     setFormat({ format: value ?? ("QCow2" as any) }); | ||||
|  | ||||
|     if (value === "QCow2") setFilename(`${origFilename}.qcow2`); | ||||
|     if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||
|     if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||
|     if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`); | ||||
|     if (value === "Raw") { | ||||
|       setFilename(`${origFilename}.raw`); | ||||
|       // Check sparse checkbox by default | ||||
|       setFormat({ format: "Raw", is_sparse: true }); | ||||
|     } | ||||
|     if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||
|     if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||
|     if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
| @@ -104,8 +106,10 @@ export function ConvertDiskImageDialog( | ||||
|           options={[ | ||||
|             { value: "QCow2" }, | ||||
|             { value: "Raw" }, | ||||
|             { value: "CompressedRaw" }, | ||||
|             { value: "CompressedQCow2" }, | ||||
|             { value: "GzCompressedRaw" }, | ||||
|             { value: "XzCompressedRaw" }, | ||||
|             { value: "GzCompressedQCow2" }, | ||||
|             { value: "XzCompressedQCow2" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
|   | ||||
| @@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css"; | ||||
| import "@fontsource/roboto/500.css"; | ||||
| import "@fontsource/roboto/700.css"; | ||||
|  | ||||
| import { ThemeProvider, createTheme } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import { App } from "./App"; | ||||
| import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; | ||||
| import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; | ||||
| import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; | ||||
| import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; | ||||
| import "./index.css"; | ||||
| import { LoadServerConfig } from "./widgets/LoadServerConfig"; | ||||
| import { ThemeProvider, createTheme } from "@mui/material"; | ||||
| import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; | ||||
| import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; | ||||
| import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; | ||||
| import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; | ||||
|  | ||||
| import { loader } from "@monaco-editor/react"; | ||||
| import * as monaco from "monaco-editor"; | ||||
| import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; | ||||
| import { configureMonacoYaml } from "monaco-yaml"; | ||||
| import YamlWorker from "monaco-yaml/yaml.worker?worker"; | ||||
|  | ||||
| // This allows to use a self hosted instance of Monaco editor | ||||
| loader.config({ monaco }); | ||||
|  | ||||
| // Add YAML support to Monaco | ||||
| configureMonacoYaml(monaco, { | ||||
|   enableSchemaRequest: false, | ||||
| }); | ||||
|  | ||||
| /// YAML worker | ||||
| window.MonacoEnvironment = { | ||||
|   getWorker(_moduleId, label) { | ||||
|     switch (label) { | ||||
|       case "editorWorkerService": | ||||
|         return new EditorWorker(); | ||||
|       case "yaml": | ||||
|         return new YamlWorker(); | ||||
|       default: | ||||
|         throw new Error(`Unknown label ${label}`); | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| const darkTheme = createTheme({ | ||||
|   palette: { | ||||
| @@ -20,9 +48,7 @@ const darkTheme = createTheme({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const root = ReactDOM.createRoot( | ||||
|   document.getElementById("root")! | ||||
| ); | ||||
| const root = ReactDOM.createRoot(document.getElementById("root")!); | ||||
| root.render( | ||||
|   <React.StrictMode> | ||||
|     <ThemeProvider theme={darkTheme}> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import VisibilityIcon from '@mui/icons-material/Visibility'; | ||||
| import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||||
| import VisibilityIcon from "@mui/icons-material/Visibility"; | ||||
| import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; | ||||
| import { | ||||
|   Alert, | ||||
|   CircularProgress, | ||||
| @@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement { | ||||
|   const canSubmit = username.length > 0 && password.length > 0; | ||||
|  | ||||
|   const [showPassword, setShowPassword] = React.useState(false); | ||||
|   const handleClickShowPassword = () => { setShowPassword((show) => !show); }; | ||||
|   const handleClickShowPassword = () => { | ||||
|     setShowPassword((show) => !show); | ||||
|   }; | ||||
|  | ||||
|   const handleMouseDownPassword = ( | ||||
|     event: React.MouseEvent<HTMLButtonElement> | ||||
| @@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement { | ||||
|               label="Username" | ||||
|               name="username" | ||||
|               value={username} | ||||
|               onChange={(e) => { setUsername(e.target.value); }} | ||||
|               onChange={(e) => { | ||||
|                 setUsername(e.target.value); | ||||
|               }} | ||||
|               autoComplete="username" | ||||
|               autoFocus | ||||
|             /> | ||||
|  | ||||
|             <FormControl fullWidth variant="outlined"> | ||||
|             <FormControl required fullWidth variant="outlined"> | ||||
|               <InputLabel htmlFor="password">Password</InputLabel> | ||||
|               <OutlinedInput | ||||
|                 required | ||||
| @@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement { | ||||
|                 type={showPassword ? "text" : "password"} | ||||
|                 id="password" | ||||
|                 value={password} | ||||
|                 onChange={(e) => { setPassword(e.target.value); }} | ||||
|                 onChange={(e) => { | ||||
|                   setPassword(e.target.value); | ||||
|                 }} | ||||
|                 autoComplete="current-password" | ||||
|                 endAdornment={ | ||||
|                   <InputAdornment position="end"> | ||||
| @@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement { | ||||
|                         onMouseDown={handleMouseDownPassword} | ||||
|                         edge="end" | ||||
|                       > | ||||
|                         {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} | ||||
|                         {showPassword ? ( | ||||
|                           <VisibilityOffIcon /> | ||||
|                         ) : ( | ||||
|                           <VisibilityIcon /> | ||||
|                         )} | ||||
|                       </IconButton> | ||||
|                     </Tooltip> | ||||
|                   </InputAdornment> | ||||
|   | ||||
							
								
								
									
										128
									
								
								virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | ||||
| import Editor from "@monaco-editor/react"; | ||||
| import RefreshIcon from "@mui/icons-material/Refresh"; | ||||
| import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material"; | ||||
| import { v4 as uuidv4 } from "uuid"; | ||||
| import { VMInfo } from "../../api/VMApi"; | ||||
| import { CheckboxInput } from "./CheckboxInput"; | ||||
| import { EditSection } from "./EditSection"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
| import { TextInput } from "./TextInput"; | ||||
|  | ||||
| interface CloudInitProps { | ||||
|   vm: VMInfo; | ||||
|   onChange?: () => void; | ||||
|   editable: boolean; | ||||
| } | ||||
|  | ||||
| export function CloudInitEditor(p: CloudInitProps): React.ReactElement { | ||||
|   return ( | ||||
|     <> | ||||
|       <EditSection> | ||||
|         {/* Attach cloud init disk */} | ||||
|         <CheckboxInput | ||||
|           {...p} | ||||
|           label="Attach Cloud Init disk" | ||||
|           checked={p.vm.cloud_init.attach_config} | ||||
|           onValueChange={(v) => { | ||||
|             p.vm.cloud_init.attach_config = v; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       </EditSection> | ||||
|       <Grid container spacing={2}> | ||||
|         <CloudInitMetadata | ||||
|           {...p} | ||||
|           editable={p.editable && p.vm.cloud_init.attach_config} | ||||
|         /> | ||||
|         <CloudInitRawUserData | ||||
|           {...p} | ||||
|           editable={p.editable && p.vm.cloud_init.attach_config} | ||||
|         /> | ||||
|       </Grid> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function CloudInitMetadata(p: CloudInitProps): React.ReactElement { | ||||
|   // Regenerate instance id | ||||
|   const reGenerateInstanceId = () => { | ||||
|     p.vm.cloud_init.instance_id = uuidv4(); | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <EditSection title="Metadata"> | ||||
|       {/* Instance ID */} | ||||
|       <TextInput | ||||
|         {...p} | ||||
|         label="Instance ID" | ||||
|         value={p.vm.cloud_init.instance_id} | ||||
|         onValueChange={(v) => { | ||||
|           p.vm.cloud_init.instance_id = v; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         endAdornment={ | ||||
|           p.editable ? ( | ||||
|             <InputAdornment position="end"> | ||||
|               <Tooltip title="Generate a new instance ID"> | ||||
|                 <IconButton onClick={reGenerateInstanceId}> | ||||
|                   <RefreshIcon /> | ||||
|                 </IconButton> | ||||
|               </Tooltip> | ||||
|             </InputAdornment> | ||||
|           ) : ( | ||||
|             <></> | ||||
|           ) | ||||
|         } | ||||
|       /> | ||||
|  | ||||
|       {/* Instance hostname */} | ||||
|       <TextInput | ||||
|         {...p} | ||||
|         label="Local hostname" | ||||
|         value={p.vm.cloud_init.local_hostname} | ||||
|         onValueChange={(v) => { | ||||
|           p.vm.cloud_init.local_hostname = v; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       {/* Data source mode */} | ||||
|       <SelectInput | ||||
|         {...p} | ||||
|         label="Data source mode" | ||||
|         value={p.vm.cloud_init.dsmode} | ||||
|         onValueChange={(v) => { | ||||
|           p.vm.cloud_init.dsmode = v as any; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         options={[ | ||||
|           { label: "None", value: undefined }, | ||||
|           { value: "Net" }, | ||||
|           { value: "Local" }, | ||||
|         ]} | ||||
|       /> | ||||
|     </EditSection> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function CloudInitRawUserData(p: CloudInitProps): React.ReactElement { | ||||
|   return ( | ||||
|     <EditSection title="User data"> | ||||
|       <Editor | ||||
|         theme="vs-dark" | ||||
|         options={{ | ||||
|           readOnly: !p.editable, | ||||
|           quickSuggestions: { other: true, comments: true, strings: true }, | ||||
|         }} | ||||
|         language="yaml" | ||||
|         height={"30vh"} | ||||
|         value={p.vm.cloud_init.user_data} | ||||
|         onChange={(v) => { | ||||
|           p.vm.cloud_init.user_data = v ?? ""; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|     </EditSection> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { TextInput } from "./TextInput"; | ||||
|  | ||||
| export function DiskSizeInput(p: { | ||||
|   editable: boolean; | ||||
|   label?: string; | ||||
|   value: number; | ||||
|   onChange?: (size: number) => void; | ||||
| }): React.ReactElement { | ||||
|   return ( | ||||
|     <TextInput | ||||
|       editable={p.editable} | ||||
|       label={p.label ?? "Disk size (GB)"} | ||||
|       size={{ | ||||
|         min: ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), | ||||
|         max: ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), | ||||
|       }} | ||||
|       value={(p.value / (1000 * 1000 * 1000)).toString()} | ||||
|       onValueChange={(v) => { | ||||
|         p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000); | ||||
|       }} | ||||
|       type="number" | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   if (!p.editable && p.vm.oem_strings.length === 0) return <></>; | ||||
|  | ||||
|   return ( | ||||
|     <EditSection | ||||
|       title="SMBIOS OEM Strings" | ||||
|   | ||||
| @@ -18,6 +18,7 @@ export function TextInput(p: { | ||||
|   style?: React.CSSProperties; | ||||
|   helperText?: string; | ||||
|   disabled?: boolean; | ||||
|   endAdornment?: React.ReactNode; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||
|  | ||||
| @@ -51,6 +52,7 @@ export function TextInput(p: { | ||||
|         input: { | ||||
|           readOnly: !p.editable, | ||||
|           type: p.type, | ||||
|           endAdornment: p.endAdornment, | ||||
|         }, | ||||
|       }} | ||||
|       variant={"standard"} | ||||
|   | ||||
| @@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { Button, IconButton, Paper, Tooltip } from "@mui/material"; | ||||
| import ExpandIcon from "@mui/icons-material/Expand"; | ||||
| import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| @@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget"; | ||||
| import { CheckboxInput } from "./CheckboxInput"; | ||||
| import { DiskBusSelect } from "./DiskBusSelect"; | ||||
| import { DiskImageSelect } from "./DiskImageSelect"; | ||||
| import { DiskSizeInput } from "./DiskSizeInput"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
| import { TextInput } from "./TextInput"; | ||||
|  | ||||
| @@ -67,6 +69,12 @@ export function VMDisksList(p: { | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {p.vm.file_disks.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> | ||||
|           No disk file yet! | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} | ||||
|  | ||||
|       {/* Disk backup */} | ||||
| @@ -93,6 +101,19 @@ function DiskInfo(p: { | ||||
|   diskImagesList: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const confirm = useConfirm(); | ||||
|  | ||||
|   const expandDisk = () => { | ||||
|     if (p.disk.resize === true) { | ||||
|       p.disk.resize = false; | ||||
|       p.disk.size = p.disk.originalSize!; | ||||
|     } else { | ||||
|       p.disk.resize = true; | ||||
|       p.disk.originalSize = p.disk.size!; | ||||
|     } | ||||
|  | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const deleteDisk = async () => { | ||||
|     if (p.disk.deleteType) { | ||||
|       p.disk.deleteType = undefined; | ||||
| @@ -115,42 +136,75 @@ function DiskInfo(p: { | ||||
|  | ||||
|   if (!p.editable || !p.disk.new) | ||||
|     return ( | ||||
|       <VMDiskFileWidget | ||||
|         {...p} | ||||
|         secondaryAction={ | ||||
|           <> | ||||
|             {p.editable && ( | ||||
|               <IconButton | ||||
|                 edge="end" | ||||
|                 aria-label="delete disk" | ||||
|                 onClick={deleteDisk} | ||||
|               > | ||||
|                 {p.disk.deleteType ? ( | ||||
|                   <Tooltip title="Cancel disk removal"> | ||||
|                     <CheckCircleIcon /> | ||||
|                   </Tooltip> | ||||
|                 ) : ( | ||||
|                   <Tooltip title="Remove disk"> | ||||
|                     <DeleteIcon /> | ||||
|                   </Tooltip> | ||||
|                 )} | ||||
|               </IconButton> | ||||
|             )} | ||||
|  | ||||
|             {p.canBackup && ( | ||||
|               <Tooltip title="Backup this disk"> | ||||
|       <> | ||||
|         <VMDiskFileWidget | ||||
|           {...p} | ||||
|           secondaryAction={ | ||||
|             <> | ||||
|               {p.editable && !p.disk.deleteType && ( | ||||
|                 <IconButton | ||||
|                   onClick={() => { | ||||
|                     p.onRequestBackup(p.disk); | ||||
|                   }} | ||||
|                   edge="end" | ||||
|                   aria-label="expand disk" | ||||
|                   onClick={expandDisk} | ||||
|                 > | ||||
|                   <Icon path={mdiHarddiskPlus} size={1} /> | ||||
|                   {p.disk.resize === true ? ( | ||||
|                     <Tooltip title="Cancel disk expansion"> | ||||
|                       <ExpandIcon color="error" /> | ||||
|                     </Tooltip> | ||||
|                   ) : ( | ||||
|                     <Tooltip title="Increase disk size"> | ||||
|                       <ExpandIcon /> | ||||
|                     </Tooltip> | ||||
|                   )} | ||||
|                 </IconButton> | ||||
|               </Tooltip> | ||||
|             )} | ||||
|           </> | ||||
|         } | ||||
|       /> | ||||
|               )} | ||||
|  | ||||
|               {p.editable && ( | ||||
|                 <IconButton | ||||
|                   edge="end" | ||||
|                   aria-label="delete disk" | ||||
|                   onClick={deleteDisk} | ||||
|                 > | ||||
|                   {p.disk.deleteType ? ( | ||||
|                     <Tooltip title="Cancel disk removal"> | ||||
|                       <CheckCircleIcon /> | ||||
|                     </Tooltip> | ||||
|                   ) : ( | ||||
|                     <Tooltip title="Remove disk"> | ||||
|                       <DeleteIcon /> | ||||
|                     </Tooltip> | ||||
|                   )} | ||||
|                 </IconButton> | ||||
|               )} | ||||
|  | ||||
|               {p.canBackup && ( | ||||
|                 <Tooltip title="Backup this disk"> | ||||
|                   <IconButton | ||||
|                     onClick={() => { | ||||
|                       p.onRequestBackup(p.disk); | ||||
|                     }} | ||||
|                   > | ||||
|                     <Icon path={mdiHarddiskPlus} size={1} /> | ||||
|                   </IconButton> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|             </> | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         {/* New disk size*/} | ||||
|         {p.disk.resize && !p.disk.deleteType && ( | ||||
|           <DiskSizeInput | ||||
|             editable | ||||
|             label="New disk size (GB)" | ||||
|             value={p.disk.size} | ||||
|             onChange={(v) => { | ||||
|               p.disk.size = v; | ||||
|               p.onChange?.(); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </> | ||||
|     ); | ||||
|  | ||||
|   return ( | ||||
| @@ -212,24 +266,32 @@ function DiskInfo(p: { | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <TextInput | ||||
|         editable={true} | ||||
|         label="Disk size (GB)" | ||||
|         size={{ | ||||
|           min: | ||||
|             ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), | ||||
|           max: | ||||
|             ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), | ||||
|         }} | ||||
|         value={(p.disk.size / (1000 * 1000 * 1000)).toString()} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         type="number" | ||||
|         disabled={!!p.disk.from_image} | ||||
|       /> | ||||
|       {/* Resize disk image */} | ||||
|       {!!p.disk.from_image && ( | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           checked={p.disk.resize} | ||||
|           label="Resize disk file" | ||||
|           onValueChange={(v) => { | ||||
|             p.disk.resize = v; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {/* Disk size */} | ||||
|       {(!p.disk.from_image || p.disk.resize === true) && ( | ||||
|         <DiskSizeInput | ||||
|           editable | ||||
|           value={p.disk.size} | ||||
|           onChange={(v) => { | ||||
|             p.disk.size = v; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {/* Disk image selection */} | ||||
|       <DiskImageSelect | ||||
|         label="Use disk image as template" | ||||
|         list={p.diskImagesList} | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { | ||||
|   ListItemAvatar, | ||||
|   ListItemText, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import Grid from "@mui/material/Grid"; | ||||
| import { NWFilter } from "../../api/NWFilterApi"; | ||||
| @@ -49,6 +50,12 @@ export function VMNetworksList(p: { | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {p.vm.networks.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> | ||||
|           No network interface defined yet! | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       <Grid container spacing={2}> | ||||
|         {/* networks list */} | ||||
|         {p.vm.networks.map((n, num) => ( | ||||
|   | ||||
| @@ -60,6 +60,7 @@ export function TokenRightsEditor(p: { | ||||
|               <TableCell align="center">Get XML definition</TableCell> | ||||
|               <TableCell align="center">Get autostart</TableCell> | ||||
|               <TableCell align="center">Set autostart</TableCell> | ||||
|               <TableCell align="center">Get CloudInit disk</TableCell> | ||||
|               <TableCell align="center">Backup disk</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
| @@ -84,6 +85,13 @@ export function TokenRightsEditor(p: { | ||||
|                 {...p} | ||||
|                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||
|               /> | ||||
|               <CellRight | ||||
|                 {...p} | ||||
|                 right={{ | ||||
|                   verb: "GET", | ||||
|                   path: "/api/vm/*/cloud_init_disk", | ||||
|                 }} | ||||
|               /> | ||||
|               <CellRight | ||||
|                 {...p} | ||||
|                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||
| @@ -123,7 +131,15 @@ export function TokenRightsEditor(p: { | ||||
|                   {...p} | ||||
|                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} | ||||
|                   parent={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||
|                 />{" "} | ||||
|                 /> | ||||
|                 <CellRight | ||||
|                   {...p} | ||||
|                   right={{ | ||||
|                     verb: "GET", | ||||
|                     path: `/api/vm/${v.uuid}/cloud_init_disk`, | ||||
|                   }} | ||||
|                   parent={{ verb: "GET", path: "/api/vm/*/cloud_init_disk" }} | ||||
|                 /> | ||||
|                 <CellRight | ||||
|                   {...p} | ||||
|                   right={{ | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import Grid from "@mui/material/Grid"; | ||||
| import React from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { validate as validateUUID } from "uuid"; | ||||
| import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||
| import { GroupApi } from "../../api/GroupApi"; | ||||
| import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | ||||
| import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | ||||
| @@ -18,6 +19,7 @@ import { AsyncWidget } from "../AsyncWidget"; | ||||
| import { TabsWidget } from "../TabsWidget"; | ||||
| import { XMLAsyncWidget } from "../XMLWidget"; | ||||
| import { CheckboxInput } from "../forms/CheckboxInput"; | ||||
| import { CloudInitEditor } from "../forms/CloudInitEditor"; | ||||
| import { EditSection } from "../forms/EditSection"; | ||||
| import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | ||||
| import { ResAutostartInput } from "../forms/ResAutostartInput"; | ||||
| @@ -27,7 +29,6 @@ import { VMDisksList } from "../forms/VMDisksList"; | ||||
| import { VMNetworksList } from "../forms/VMNetworksList"; | ||||
| import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | ||||
| import { VMScreenshot } from "./VMScreenshot"; | ||||
| import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||
|  | ||||
| interface DetailsProps { | ||||
|   vm: VMInfo; | ||||
| @@ -89,6 +90,7 @@ enum VMTab { | ||||
|   General = 0, | ||||
|   Storage, | ||||
|   Network, | ||||
|   CloudInit, | ||||
|   Advanced, | ||||
|   XML, | ||||
|   Danger, | ||||
| @@ -116,6 +118,11 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | ||||
|           { label: "General", value: VMTab.General, visible: true }, | ||||
|           { label: "Storage", value: VMTab.Storage, visible: true }, | ||||
|           { label: "Network", value: VMTab.Network, visible: true }, | ||||
|           { | ||||
|             label: "Cloud Init", | ||||
|             value: VMTab.CloudInit, | ||||
|             visible: p.editable || p.vm.cloud_init.attach_config, | ||||
|           }, | ||||
|           { label: "Avanced", value: VMTab.Advanced, visible: true }, | ||||
|  | ||||
|           { | ||||
| @@ -135,6 +142,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | ||||
|       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} | ||||
|       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} | ||||
|       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} | ||||
|       {currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />} | ||||
|       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} | ||||
|       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} | ||||
|       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} | ||||
| @@ -381,6 +389,10 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement { | ||||
|   return <VMNetworksList {...p} />; | ||||
| } | ||||
|  | ||||
| function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement { | ||||
|   return <CloudInitEditor {...p} />; | ||||
| } | ||||
|  | ||||
| function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { | ||||
|   return ( | ||||
|     <Grid container spacing={2}> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user