From bdb2f6427dfd54d6368563ad651302cda80d02a5 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Thu, 26 Oct 2023 11:43:05 +0200 Subject: [PATCH] Created first disk --- virtweb_backend/src/app_config.rs | 14 +- virtweb_backend/src/constants.rs | 12 ++ .../src/controllers/server_controller.rs | 11 ++ virtweb_backend/src/libvirt_lib_structures.rs | 8 +- .../src/libvirt_rest_structures.rs | 74 ++++++++-- virtweb_backend/src/main.rs | 4 +- virtweb_backend/src/utils/disks_utils.rs | 133 ++++++++++++++++++ virtweb_backend/src/utils/files_utils.rs | 5 +- virtweb_backend/src/utils/mod.rs | 1 + virtweb_frontend/src/api/ServerApi.ts | 2 + virtweb_frontend/src/api/VMApi.ts | 16 +++ .../src/widgets/forms/TextInput.tsx | 22 ++- .../src/widgets/forms/VMDisksList.tsx | 114 +++++++++++++++ .../src/widgets/vms/VMDetails.tsx | 2 + 14 files changed, 393 insertions(+), 25 deletions(-) create mode 100644 virtweb_backend/src/utils/disks_utils.rs create mode 100644 virtweb_frontend/src/widgets/forms/VMDisksList.tsx diff --git a/virtweb_backend/src/app_config.rs b/virtweb_backend/src/app_config.rs index 5734e63..6ae8800 100644 --- a/virtweb_backend/src/app_config.rs +++ b/virtweb_backend/src/app_config.rs @@ -1,3 +1,4 @@ +use crate::libvirt_lib_structures::DomainXMLUuid; use clap::Parser; use std::path::{Path, PathBuf}; @@ -156,9 +157,18 @@ impl AppConfig { self.storage_path().join("iso") } - /// Get VM vnc sockets + /// Get VM vnc sockets directory pub fn vnc_sockets_path(&self) -> PathBuf { - self.storage_path().to_path_buf() + self.storage_path().join("vnc") + } + + /// Get VM vnc sockets directory + pub fn disks_storage_path(&self) -> PathBuf { + self.storage_path().join("disks") + } + + pub fn vm_storage_path(&self, id: DomainXMLUuid) -> PathBuf { + self.disks_storage_path().join(id.as_string()) } } diff --git a/virtweb_backend/src/constants.rs b/virtweb_backend/src/constants.rs index 09e3aa3..95b95c5 100644 --- a/virtweb_backend/src/constants.rs +++ b/virtweb_backend/src/constants.rs @@ -31,3 +31,15 @@ pub const MIN_VM_MEMORY: usize = 100; /// Max VM memory size (MB) pub const MAX_VM_MEMORY: usize = 64000; + +/// Disk name min length +pub const DISK_NAME_MIN_LEN: usize = 2; + +/// Disk name max length +pub const DISK_NAME_MAX_LEN: usize = 10; + +/// Disk size min (MB) +pub const DISK_SIZE_MIN: usize = 100; + +/// Disk size max (MB) +pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2; diff --git a/virtweb_backend/src/controllers/server_controller.rs b/virtweb_backend/src/controllers/server_controller.rs index 53c7563..fbb0602 100644 --- a/virtweb_backend/src/controllers/server_controller.rs +++ b/virtweb_backend/src/controllers/server_controller.rs @@ -1,5 +1,6 @@ use crate::app_config::AppConfig; use crate::constants; +use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN}; use crate::controllers::{HttpResult, LibVirtReq}; use crate::extractors::local_auth_extractor::LocalAuthEnabled; use crate::libvirt_rest_structures::HypervisorInfo; @@ -31,6 +32,8 @@ struct ServerConstraints { name_size: LenConstraints, title_size: LenConstraints, memory_size: LenConstraints, + disk_name_size: LenConstraints, + disk_size: LenConstraints, } pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { @@ -48,6 +51,14 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { min: constants::MIN_VM_MEMORY, max: constants::MAX_VM_MEMORY, }, + disk_name_size: LenConstraints { + min: DISK_NAME_MIN_LEN, + max: DISK_NAME_MAX_LEN, + }, + disk_size: LenConstraints { + min: DISK_SIZE_MIN, + max: DISK_SIZE_MAX, + }, }, }) } diff --git a/virtweb_backend/src/libvirt_lib_structures.rs b/virtweb_backend/src/libvirt_lib_structures.rs index 7debd72..93bdb1c 100644 --- a/virtweb_backend/src/libvirt_lib_structures.rs +++ b/virtweb_backend/src/libvirt_lib_structures.rs @@ -6,6 +6,9 @@ impl DomainXMLUuid { Ok(Self(uuid::Uuid::parse_str(s)?)) } + pub fn new_random() -> Self { + Self(uuid::Uuid::new_v4()) + } pub fn as_string(&self) -> String { self.0.to_string() } @@ -93,7 +96,8 @@ pub struct DiskXML { pub driver: DiskDriverXML, pub source: DiskSourceXML, pub target: DiskTargetXML, - pub readonly: DiskReadOnlyXML, + #[serde(skip_serializing_if = "Option::is_none")] + pub readonly: Option, pub boot: DiskBootXML, #[serde(skip_serializing_if = "Option::is_none")] pub address: Option, @@ -106,6 +110,8 @@ pub struct DiskDriverXML { pub name: String, #[serde(rename(serialize = "@type"))] pub r#type: String, + #[serde(default, rename(serialize = "@cache"))] + pub r#cache: String, } #[derive(serde::Serialize, serde::Deserialize)] diff --git a/virtweb_backend/src/libvirt_rest_structures.rs b/virtweb_backend/src/libvirt_rest_structures.rs index 82617e2..85616f0 100644 --- a/virtweb_backend/src/libvirt_rest_structures.rs +++ b/virtweb_backend/src/libvirt_rest_structures.rs @@ -6,6 +6,7 @@ use crate::libvirt_lib_structures::{ ACPIXML, OSXML, }; use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; +use crate::utils::disks_utils::Disk; use crate::utils::files_utils; use lazy_regex::regex; use std::ops::{Div, Mul}; @@ -74,7 +75,8 @@ pub struct VMInfo { pub vnc_access: bool, /// Attach an ISO file pub iso_file: Option, - // TODO : storage + /// Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest + pub disks: Vec, // TODO : autostart // TODO : network interface } @@ -86,11 +88,14 @@ impl VMInfo { return Err(StructureExtraction("VM name is invalid!").into()); } - if let Some(n) = &self.uuid { + let uuid = if let Some(n) = self.uuid { if !n.is_valid() { return Err(StructureExtraction("VM UUID is invalid!").into()); } - } + n + } else { + DomainXMLUuid::new_random() + }; if let Some(n) = &self.genid { if !n.is_valid() { @@ -127,6 +132,7 @@ impl VMInfo { driver: DiskDriverXML { name: "qemu".to_string(), r#type: "raw".to_string(), + cache: "none".to_string(), }, source: DiskSourceXML { file: path.to_string_lossy().to_string(), @@ -135,17 +141,11 @@ impl VMInfo { dev: "hdc".to_string(), bus: "usb".to_string(), }, - readonly: DiskReadOnlyXML {}, + readonly: Some(DiskReadOnlyXML {}), boot: DiskBootXML { order: "1".to_string(), }, - address: None, /*DiskAddressXML { - r#type: "drive".to_string(), - controller: "0".to_string(), - bus: "1".to_string(), - target: "0".to_string(), - unit: "0".to_string(), - },*/ + address: None, }) } @@ -161,10 +161,52 @@ impl VMInfo { false => None, }; + // Check disks name for duplicates + for disk in &self.disks { + if self.disks.iter().filter(|d| d.name == disk.name).count() > 1 { + return Err(StructureExtraction("Two differents disks have the same name!").into()); + } + } + + // Apply disks configuration + for disk in self.disks { + disk.check_config()?; + disk.apply_config(uuid)?; + + if disk.delete { + continue; + } + + disks.push(DiskXML { + r#type: "file".to_string(), + device: "disk".to_string(), + driver: DiskDriverXML { + name: "qemu".to_string(), + r#type: "raw".to_string(), + cache: "none".to_string(), + }, + source: DiskSourceXML { + file: disk.disk_path(uuid).to_string_lossy().to_string(), + }, + target: DiskTargetXML { + dev: format!( + "vd{}", + ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] + ), + bus: "virtio".to_string(), + }, + readonly: None, + boot: DiskBootXML { + order: (disks.len() + 1).to_string(), + }, + address: None, + }) + } + Ok(DomainXML { r#type: "kvm".to_string(), name: self.name, - uuid: self.uuid, + uuid: Some(uuid), genid: self.genid.map(|i| i.0), title: self.title, description: self.description, @@ -239,6 +281,14 @@ impl VMInfo { .iter() .find(|d| d.device == "cdrom") .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()), + + disks: domain + .devices + .disks + .iter() + .filter(|d| d.device == "disk") + .map(|d| Disk::load_from_file(&d.source.file).unwrap()) + .collect(), }) } } diff --git a/virtweb_backend/src/main.rs b/virtweb_backend/src/main.rs index 323f1b8..7ee1627 100644 --- a/virtweb_backend/src/main.rs +++ b/virtweb_backend/src/main.rs @@ -33,7 +33,9 @@ async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 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().iso_storage_path()).unwrap(); + files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); + files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); let conn = Data::new(LibVirtClient( LibVirtActor::connect() diff --git a/virtweb_backend/src/utils/disks_utils.rs b/virtweb_backend/src/utils/disks_utils.rs new file mode 100644 index 0000000..b450e96 --- /dev/null +++ b/virtweb_backend/src/utils/disks_utils.rs @@ -0,0 +1,133 @@ +use crate::app_config::AppConfig; +use crate::constants; +use crate::libvirt_lib_structures::DomainXMLUuid; +use crate::utils::files_utils; +use lazy_regex::regex; +use std::os::linux::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(thiserror::Error, Debug)] +enum DisksError { + #[error("DiskParseError: {0}")] + Parse(&'static str), + #[error("DiskConfigError: {0}")] + Config(&'static str), + #[error("DiskCreateError")] + Create, +} + +/// Type of disk allocation +#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum DiskAllocType { + Fixed, + Sparse, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Disk { + /// Disk size, in megabytes + pub size: usize, + /// Disk name + pub name: String, + pub alloc_type: DiskAllocType, + /// Set this variable to true to delete the disk + pub delete: bool, +} + +impl Disk { + pub fn load_from_file(path: &str) -> anyhow::Result { + let file = Path::new(path); + + if !file.is_file() { + return Err(DisksError::Parse("Path is not a file!").into()); + } + + let metadata = file.metadata()?; + + // Approximate estimation + let is_sparse = metadata.len() / 512 >= metadata.st_blocks(); + + Ok(Self { + size: metadata.len() as usize / (1000 * 1000), + name: path.rsplit_once('/').unwrap().1.to_string(), + alloc_type: match is_sparse { + true => DiskAllocType::Sparse, + false => DiskAllocType::Fixed, + }, + delete: false, + }) + } + + pub fn check_config(&self) -> anyhow::Result<()> { + if constants::DISK_NAME_MIN_LEN > self.name.len() + || constants::DISK_NAME_MAX_LEN < self.name.len() + { + return Err(DisksError::Config("Disk name length is invalid").into()); + } + + if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) { + return Err(DisksError::Config("Disk name contains invalid characters!").into()); + } + + if self.size < constants::DISK_SIZE_MIN || self.size > constants::DISK_SIZE_MAX { + return Err(DisksError::Config("Disk size is invalid!").into()); + } + + Ok(()) + } + + /// Get disk path + pub fn disk_path(&self, id: DomainXMLUuid) -> PathBuf { + let domain_dir = AppConfig::get().vm_storage_path(id); + domain_dir.join(&self.name) + } + + /// Apply disk configuration + pub fn apply_config(&self, id: DomainXMLUuid) -> anyhow::Result<()> { + self.check_config()?; + + let file = self.disk_path(id); + files_utils::create_directory_if_missing(file.parent().unwrap())?; + + // Delete file if requested + if self.delete { + if !file.exists() { + log::debug!("File {file:?} does not exists, so it was not deleted"); + return Ok(()); + } + + log::info!("Deleting {file:?}"); + std::fs::remove_file(file)?; + return Ok(()); + } + + if file.exists() { + log::debug!("File {file:?} does not exists, so it was not touched"); + return Ok(()); + } + + let mut cmd = Command::new("/usr/bin/dd"); + cmd.arg("if=/dev/zero") + .arg(format!("of={}", file.to_string_lossy())) + .arg("bs=1M"); + + match self.alloc_type { + DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)), + DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"), + }; + + let res = cmd.output()?; + + if !res.status.success() { + log::error!( + "Failed to create disk! stderr={} stdout={}", + String::from_utf8_lossy(&res.stderr), + String::from_utf8_lossy(&res.stdout) + ); + return Err(DisksError::Create.into()); + } + + Ok(()) + } +} diff --git a/virtweb_backend/src/utils/files_utils.rs b/virtweb_backend/src/utils/files_utils.rs index f261973..f4503a3 100644 --- a/virtweb_backend/src/utils/files_utils.rs +++ b/virtweb_backend/src/utils/files_utils.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::Path; const INVALID_CHARS: [&str; 19] = [ "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=", @@ -11,7 +11,8 @@ pub fn check_file_name(name: &str) -> bool { } /// Create directory if missing -pub fn create_directory_if_missing(path: &PathBuf) -> anyhow::Result<()> { +pub fn create_directory_if_missing>(path: P) -> anyhow::Result<()> { + let path = path.as_ref(); if !path.exists() { std::fs::create_dir_all(path)?; } diff --git a/virtweb_backend/src/utils/mod.rs b/virtweb_backend/src/utils/mod.rs index 01babdb..066b06d 100644 --- a/virtweb_backend/src/utils/mod.rs +++ b/virtweb_backend/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod disks_utils; pub mod files_utils; pub mod rand_utils; pub mod time_utils; diff --git a/virtweb_frontend/src/api/ServerApi.ts b/virtweb_frontend/src/api/ServerApi.ts index 841c654..792ffa5 100644 --- a/virtweb_frontend/src/api/ServerApi.ts +++ b/virtweb_frontend/src/api/ServerApi.ts @@ -13,6 +13,8 @@ export interface ServerConstraints { name_size: LenConstraint; title_size: LenConstraint; memory_size: LenConstraint; + disk_name_size: LenConstraint; + disk_size: LenConstraint; } export interface LenConstraint { diff --git a/virtweb_frontend/src/api/VMApi.ts b/virtweb_frontend/src/api/VMApi.ts index 2cfe001..897a9b9 100644 --- a/virtweb_frontend/src/api/VMApi.ts +++ b/virtweb_frontend/src/api/VMApi.ts @@ -17,6 +17,18 @@ export type VMState = | "PowerManagementSuspended" | "Other"; +export type DiskAllocType = "Sparse" | "Fixed"; + +export interface VMDisk { + size: number; + name: string; + alloc_type: DiskAllocType; + delete: boolean; + + // application attribute + new?: boolean; +} + interface VMInfoInterface { name: string; uuid?: string; @@ -28,6 +40,7 @@ interface VMInfoInterface { memory: number; vnc_access: boolean; iso_file?: string; + disks: VMDisk[]; } export class VMInfo implements VMInfoInterface { @@ -41,6 +54,7 @@ export class VMInfo implements VMInfoInterface { memory: number; vnc_access: boolean; iso_file?: string; + disks: VMDisk[]; constructor(int: VMInfoInterface) { this.name = int.name; @@ -53,6 +67,7 @@ export class VMInfo implements VMInfoInterface { this.memory = int.memory; this.vnc_access = int.vnc_access; this.iso_file = int.iso_file; + this.disks = int.disks; } static NewEmpty(): VMInfo { @@ -62,6 +77,7 @@ export class VMInfo implements VMInfoInterface { architecture: "x86_64", memory: 1024, vnc_access: true, + disks: [], }); } diff --git a/virtweb_frontend/src/widgets/forms/TextInput.tsx b/virtweb_frontend/src/widgets/forms/TextInput.tsx index f166ebb..6264f5f 100644 --- a/virtweb_frontend/src/widgets/forms/TextInput.tsx +++ b/virtweb_frontend/src/widgets/forms/TextInput.tsx @@ -18,6 +18,19 @@ export function TextInput(p: { }): React.ReactElement { if (((!p.editable && p.value) ?? "") === "") return <>; + let valueError = undefined; + if (p.value && p.value.length > 0) { + if (p.size?.min && p.type !== "number" && p.value.length < p.size.min) + valueError = "Invalid value size"; + if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!"; + if ( + p.type === "number" && + p.size && + (Number(p.value) > p.size.max || Number(p.value) < p.size.min) + ) + valueError = "Invalide size range!"; + } + return ( 0 && - !p.checkValue(p.value)) || - false - } + error={valueError !== undefined} + helperText={valueError} /> ); } diff --git a/virtweb_frontend/src/widgets/forms/VMDisksList.tsx b/virtweb_frontend/src/widgets/forms/VMDisksList.tsx new file mode 100644 index 0000000..1fcbb8a --- /dev/null +++ b/virtweb_frontend/src/widgets/forms/VMDisksList.tsx @@ -0,0 +1,114 @@ +import { + Avatar, + Button, + ListItem, + ListItemAvatar, + ListItemText, + Paper, +} from "@mui/material"; +import { VMDisk, VMInfo } from "../../api/VMApi"; +import { filesize } from "filesize"; +import Icon from "@mdi/react"; +import { mdiHarddisk } from "@mdi/js"; +import { TextInput } from "./TextInput"; +import { ServerApi } from "../../api/ServerApi"; +import { SelectInput } from "./SelectInput"; + +export function VMDisksList(p: { + vm: VMInfo; + onChange?: () => void; + editable: boolean; +}): React.ReactElement { + const addNewDisk = () => { + p.vm.disks.push({ + alloc_type: "Sparse", + size: 10000, + delete: false, + name: `disk${p.vm.disks.length}`, + new: true, + }); + p.onChange?.(); + }; + + return ( + <> + {/* disks list */} + {p.vm.disks.map((d, num) => ( + + ))} + + {p.editable && } + + ); +} + +function DiskInfo(p: { + editable: boolean; + disk: VMDisk; + onChange?: () => void; +}): React.ReactElement { + if (!p.editable || !p.disk.new) + return ( + + + + + + + + {/* TODO delete disk if editable */} + + ); + + return ( + + /^[a-zA-Z0-9]+$/.test(v)} + value={p.disk.name} + onValueChange={(v) => { + p.disk.name = v ?? ""; + p.onChange?.(); + }} + /> + + { + p.disk.size = Number(v ?? "0"); + p.onChange?.(); + }} + type="number" + /> + + { + p.disk.alloc_type = v as any; + p.onChange?.(); + }} + /> + + ); +} diff --git a/virtweb_frontend/src/widgets/vms/VMDetails.tsx b/virtweb_frontend/src/widgets/vms/VMDetails.tsx index b33e4a1..068f3b7 100644 --- a/virtweb_frontend/src/widgets/vms/VMDetails.tsx +++ b/virtweb_frontend/src/widgets/vms/VMDetails.tsx @@ -11,6 +11,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; import { AsyncWidget } from "../AsyncWidget"; import React from "react"; import { filesize } from "filesize"; +import { VMDisksList } from "../forms/VMDisksList"; interface DetailsProps { vm: VMInfo; @@ -174,6 +175,7 @@ function VMDetailsInner( }), ]} /> + );