Compare commits

...

19 Commits

Author SHA1 Message Date
572490a930 Update Rust crate actix-http to 3.11.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-30 00:10:28 +00:00
d5fbc24c96 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 15:52:23 +02:00
d765f9c2c3 Improve raw sparse conversion
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 15:44:21 +02:00
21fd5de139 Can decompress raw files to sparse file 2025-05-29 15:41:08 +02:00
42f22c110c Can compress raw files 2025-05-29 15:33:32 +02:00
9822c5a72a Can convert QCow2 to QCow2 2025-05-29 15:13:31 +02:00
452a395525 Can convert QCow2 to raw 2025-05-29 15:00:14 +02:00
80d81c34bb Add support of sparse files for QCow2 to sparse conversion 2025-05-29 14:51:57 +02:00
b9353326f5 Can convert QCow2 to raw file 2025-05-29 14:47:29 +02:00
3ffc64f129 Can compress QCow2 2025-05-29 14:22:09 +02:00
e869517bb1 Can decompress QCow2 2025-05-29 14:18:27 +02:00
90f4bf35e9 Add checkbox for sparse file conversion
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 12:18:26 +02:00
80d6fe0298 Can perform basic file copy 2025-05-29 12:15:52 +02:00
e017fe96d5 Add the logic which will call disk image conversion from disk image route
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 11:37:11 +02:00
e7ac0198ab Can download disk images
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 10:23:26 +02:00
927a51cda7 Can delete uploaded disk images 2025-05-29 08:45:38 +02:00
615dc1ed83 Display the list of uploaded disk images 2025-05-29 08:33:19 +02:00
20de618568 Generalize disk file creation logic
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 08:03:42 +02:00
7451f1b7b4 Split file_disks_utils into two files
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 07:40:42 +02:00
19 changed files with 981 additions and 217 deletions

View File

@ -19,7 +19,7 @@ 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"
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }

View File

@ -245,7 +245,7 @@ impl AppConfig {
storage_path.canonicalize().unwrap()
}
/// Get iso storage directory
/// Get iso files storage directory
pub fn iso_storage_path(&self) -> PathBuf {
self.storage_path().join("iso")
}
@ -265,15 +265,17 @@ impl AppConfig {
self.vnc_sockets_path().join(format!("vnc-{}", name))
}
/// Get VM vnc sockets directory
pub fn disks_storage_path(&self) -> PathBuf {
/// Get VM root disks storage directory
pub fn root_vm_disks_storage_path(&self) -> PathBuf {
self.storage_path().join("disks")
}
/// Get specific VM disk storage directory
pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
self.disks_storage_path().join(id.as_string())
self.root_vm_disks_storage_path().join(id.as_string())
}
/// Get the path were VM definitions are backed up
pub fn definitions_path(&self) -> PathBuf {
self.storage_path().join("definitions")
}

View File

@ -1,3 +1,5 @@
use crate::utils::file_size_utils::FileSize;
/// Name of the cookie that contains session information
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
@ -47,10 +49,10 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
pub const DISK_NAME_MAX_LEN: usize = 10;
/// Disk size min (B)
pub const DISK_SIZE_MIN: usize = 100 * 1000 * 1000;
pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
/// Disk size max (B)
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 1000 * 1000 * 2;
pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
/// Net nat entry comment max size
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
@ -121,3 +123,15 @@ pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img";
/// IP program path
pub const IP_PROGRAM: &str = "/usr/sbin/ip";
/// Copy program path
pub const COPY_PROGRAM: &str = "/bin/cp";
/// Gzip program path
pub const GZIP_PROGRAM: &str = "/usr/bin/gzip";
/// Bash program
pub const BASH_PROGRAM: &str = "/usr/bin/bash";
/// DD program
pub const DD_PROGRAM: &str = "/usr/bin/dd";

View File

@ -1,11 +1,12 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::controllers::HttpResult;
use crate::utils::file_disks_utils::DiskFileInfo;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::files_utils;
use actix_files::NamedFile;
use actix_multipart::form::MultipartForm;
use actix_multipart::form::tempfile::TempFile;
use actix_web::HttpResponse;
use actix_web::{HttpRequest, HttpResponse, web};
#[derive(Debug, MultipartForm)]
pub struct UploadDiskImageForm {
@ -67,3 +68,97 @@ pub async fn get_list() -> HttpResult {
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct DiskFilePath {
filename: String,
}
/// Download disk image
pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get()
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
Ok(NamedFile::open(file_path)?.into_response(&req))
}
#[derive(serde::Deserialize)]
pub struct ConvertDiskImageRequest {
dest_file_name: String,
#[serde(flatten)]
format: DiskFileFormat,
}
/// Convert disk image into a new format
pub async fn convert(
p: web::Path<DiskFilePath>,
req: web::Json<ConvertDiskImageRequest>,
) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
}
if !files_utils::check_file_name(&req.dest_file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
}
if !req
.format
.ext()
.iter()
.any(|e| req.dest_file_name.ends_with(e))
{
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
}
let src_file_path = AppConfig::get()
.disk_images_storage_path()
.join(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?;
let dst_file_path = AppConfig::get()
.disk_images_storage_path()
.join(&req.dest_file_name);
if dst_file_path.exists() {
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
}
// Perform conversion
if let Err(e) = src.convert(&dst_file_path, req.format) {
log::error!("Disk file conversion error: {e}");
return Ok(
HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}"))
);
}
Ok(HttpResponse::Ok().json(src))
}
/// Delete a disk image
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get()
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
std::fs::remove_file(file_path)?;
Ok(HttpResponse::Accepted().finish())
}

View File

@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
}
#[derive(serde::Deserialize)]
pub struct DownloadFilePath {
pub struct IsoFilePath {
filename: String,
}
/// Download ISO file
pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult {
pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) ->
}
/// Delete ISO file
pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult {
pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}

View File

@ -46,6 +46,7 @@ struct ServerConstraints {
memory_size: LenConstraints,
disk_name_size: LenConstraints,
disk_size: LenConstraints,
disk_image_name_size: LenConstraints,
net_name_size: LenConstraints,
net_title_size: LenConstraints,
net_nat_comment_size: LenConstraints,
@ -87,10 +88,12 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
max: DISK_NAME_MAX_LEN,
},
disk_size: LenConstraints {
min: DISK_SIZE_MIN,
max: DISK_SIZE_MAX,
min: DISK_SIZE_MIN.as_bytes(),
max: DISK_SIZE_MAX.as_bytes(),
},
disk_image_name_size: LenConstraints { min: 5, max: 220 },
net_name_size: LenConstraints { min: 2, max: 50 },
net_title_size: LenConstraints { min: 0, max: 50 },
net_nat_comment_size: LenConstraints {

View File

@ -4,9 +4,9 @@ use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_lib_structures::domain::*;
use crate::libvirt_rest_structures::LibVirtStructError;
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
use crate::utils::file_disks_utils::{VMDiskFormat, VMFileDisk};
use crate::utils::files_utils;
use crate::utils::files_utils::convert_size_unit_to_mb;
use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk};
use lazy_regex::regex;
use num::Integer;

View File

@ -60,7 +60,8 @@ async fn main() -> std::io::Result<()> {
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path())
.unwrap();
files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
@ -344,6 +345,18 @@ async fn main() -> std::io::Result<()> {
"/api/disk_images/list",
web::get().to(disk_images_controller::get_list),
)
.route(
"/api/disk_images/{filename}",
web::get().to(disk_images_controller::download),
)
.route(
"/api/disk_images/{filename}/convert",
web::post().to(disk_images_controller::convert),
)
.route(
"/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete),
)
// API tokens controller
.route(
"/api/token/create",

View File

@ -1,8 +1,7 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::libvirt_lib_structures::XMLUuid;
use crate::utils::files_utils;
use lazy_regex::regex;
use crate::utils::file_size_utils::FileSize;
use std::fs::File;
use std::os::linux::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
@ -12,193 +11,48 @@ use std::time::UNIX_EPOCH;
enum DisksError {
#[error("DiskParseError: {0}")]
Parse(&'static str),
#[error("DiskConfigError: {0}")]
Config(&'static str),
#[error("DiskCreateError")]
Create,
#[error("DiskConvertError: {0}")]
Convert(String),
}
/// Type of disk allocation
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum VMDiskAllocType {
Fixed,
Sparse,
}
/// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "format")]
pub enum VMDiskFormat {
Raw {
/// Type of disk allocation
alloc_type: VMDiskAllocType,
},
QCow2,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct VMFileDisk {
/// Disk name
pub name: String,
/// Disk size, in bytes
pub size: usize,
/// Disk format
#[serde(flatten)]
pub format: VMDiskFormat,
/// Set this variable to true to delete the disk
pub delete: bool,
}
impl VMFileDisk {
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
let file = Path::new(path);
let info = DiskFileInfo::load_file(file)?;
Ok(Self {
name: info.name,
// Get only the virtual size of the file
size: match info.format {
DiskFileFormat::Raw { .. } => info.file_size,
DiskFileFormat::QCow2 { virtual_size } => virtual_size,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
format: match info.format {
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw {
alloc_type: match is_sparse {
true => VMDiskAllocType::Sparse,
false => VMDiskAllocType::Fixed,
},
},
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
delete: false,
})
}
pub fn check_config(&self) -> anyhow::Result<()> {
if constants::DISK_NAME_MIN_LEN > self.name.len()
|| constants::DISK_NAME_MAX_LEN < self.name.len()
{
return Err(DisksError::Config("Disk name length is invalid").into());
}
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
return Err(DisksError::Config("Disk name contains invalid characters!").into());
}
// Check disk size
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
return Err(DisksError::Config("Disk size is invalid!").into());
}
Ok(())
}
/// Get disk path
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id);
let file_name = match self.format {
VMDiskFormat::Raw { .. } => self.name.to_string(),
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
};
domain_dir.join(&file_name)
}
/// Apply disk configuration
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
self.check_config()?;
let file = self.disk_path(id);
files_utils::create_directory_if_missing(file.parent().unwrap())?;
// Delete file if requested
if self.delete {
if !file.exists() {
log::debug!("File {file:?} does not exists, so it was not deleted");
return Ok(());
}
log::info!("Deleting {file:?}");
std::fs::remove_file(file)?;
return Ok(());
}
if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched");
return Ok(());
}
// Prepare command to create file
let res = match self.format {
VMDiskFormat::Raw { alloc_type } => {
let mut cmd = Command::new("/usr/bin/dd");
cmd.arg("if=/dev/zero")
.arg(format!("of={}", file.to_string_lossy()))
.arg("bs=1M");
match alloc_type {
VMDiskAllocType::Fixed => cmd.arg(format!("count={}", self.size_mb())),
VMDiskAllocType::Sparse => {
cmd.arg(format!("seek={}", self.size_mb())).arg("count=0")
}
};
cmd.output()?
}
VMDiskFormat::QCow2 => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("create")
.arg("-f")
.arg("qcow2")
.arg(file)
.arg(format!("{}M", self.size_mb()));
cmd.output()?
}
};
// Execute Linux command
if !res.status.success() {
log::error!(
"Failed to create disk! stderr={} stdout={}",
String::from_utf8_lossy(&res.stderr),
String::from_utf8_lossy(&res.stdout)
);
return Err(DisksError::Create.into());
}
Ok(())
}
/// Get the size of file disk in megabytes
pub fn size_mb(&self) -> usize {
self.size / (1000 * 1000)
}
}
#[derive(Debug, serde::Serialize)]
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(tag = "format")]
pub enum DiskFileFormat {
Raw { is_sparse: bool },
QCow2 { virtual_size: usize },
Raw {
#[serde(default)]
is_sparse: bool,
},
QCow2 {
#[serde(default)]
virtual_size: FileSize,
},
CompressedRaw,
CompressedQCow2,
}
impl DiskFileFormat {
pub fn ext(&self) -> &'static [&'static str] {
match self {
DiskFileFormat::Raw { .. } => &["raw", ""],
DiskFileFormat::QCow2 { .. } => &["qcow2"],
DiskFileFormat::CompressedRaw => &["raw.gz"],
DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
}
}
}
/// Disk file information
#[derive(serde::Serialize)]
pub struct DiskFileInfo {
file_size: usize,
pub file_path: PathBuf,
pub file_size: FileSize,
#[serde(flatten)]
format: DiskFileFormat,
file_name: String,
name: String,
created: u64,
pub format: DiskFileFormat,
pub file_name: String,
pub name: String,
pub created: u64,
}
impl DiskFileInfo {
@ -234,8 +88,9 @@ impl DiskFileInfo {
};
Ok(Self {
file_path: file.to_path_buf(),
name,
file_size: metadata.len() as usize,
file_size: FileSize::from_bytes(metadata.len() as usize),
format,
file_name: file
.file_name()
@ -249,6 +104,218 @@ impl DiskFileInfo {
.as_secs(),
})
}
/// Create a new empty disk
pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
// Prepare command to create file
let res = match format {
DiskFileFormat::Raw { is_sparse } => {
let mut cmd = Command::new("/usr/bin/dd");
cmd.arg("if=/dev/zero")
.arg(format!("of={}", file.to_string_lossy()))
.arg("bs=1M");
match is_sparse {
false => cmd.arg(format!("count={}", size.as_mb())),
true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"),
};
cmd.output()?
}
DiskFileFormat::QCow2 { virtual_size } => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("create")
.arg("-f")
.arg("qcow2")
.arg(file)
.arg(format!("{}M", virtual_size.as_mb()));
cmd.output()?
}
_ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
};
// Execute Linux command
if !res.status.success() {
log::error!(
"Failed to create disk! stderr={} stdout={}",
String::from_utf8_lossy(&res.stderr),
String::from_utf8_lossy(&res.stdout)
);
return Err(DisksError::Create.into());
}
Ok(())
}
/// Copy / convert file disk image into a new destination with optionally a new file format
pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> {
// Create a temporary directory to perform the operation
let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
let temp_file = temp_dir
.path()
.join(format!("temp_file.{}", dest_format.ext()[0]));
// Prepare the conversion
let mut cmd = match (self.format, dest_format) {
// Decompress QCow2
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--decompress")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Compress QCow2
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Convert QCow2 to Raw file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
if !is_sparse {
cmd.args(["-S", "0"]);
}
cmd
}
// Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of
// file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert")
.arg("-O")
.arg("qcow2")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// Convert Raw to QCow2 file
(DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
cmd
}
// Render raw file non sparse
(DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => {
let mut cmd = Command::new(constants::COPY_PROGRAM);
cmd.arg("--sparse=never")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// Render raw file sparse
(DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => {
let mut cmd = Command::new(constants::DD_PROGRAM);
cmd.arg("conv=sparse")
.arg(format!("if={}", self.file_path.display()))
.arg(format!("of={}", temp_file.display()));
cmd
}
// Compress Raw
(DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Decompress Raw to not sparse file
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--decompress")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Decompress Raw to sparse file
// https://benou.fr/www/ben/decompressing-sparse-files.html
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
let mut cmd = Command::new(constants::BASH_PROGRAM);
cmd.arg("-c").arg(format!(
"{} -d -c {} | {} conv=sparse of={}",
constants::GZIP_PROGRAM,
self.file_path.display(),
constants::DD_PROGRAM,
temp_file.display()
));
cmd
}
// Dumb copy of file
(a, b) if a == b => {
let mut cmd = Command::new(constants::COPY_PROGRAM);
cmd.arg("--sparse=auto")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// By default, conversion is unsupported
(src, dest) => {
return Err(DisksError::Convert(format!(
"Conversion from {src:?} to {dest:?} is not supported!"
))
.into());
}
};
// Execute the conversion
let command_s = format!(
"{} {}",
cmd.get_program().display(),
cmd.get_args()
.map(|a| format!("'{}'", a.display()))
.collect::<Vec<String>>()
.join(" ")
);
let cmd_output = cmd.output()?;
if !cmd_output.status.success() {
return Err(DisksError::Convert(format!(
"Command failed:\n{command_s}\nStatus: {}\nstdout: {}\nstderr: {}",
cmd_output.status,
String::from_utf8_lossy(&cmd_output.stdout),
String::from_utf8_lossy(&cmd_output.stderr)
))
.into());
}
// Check the file was created
if !temp_file.is_file() {
return Err(DisksError::Convert(
"Temporary was not created after execution of command!".to_string(),
)
.into());
}
// Move the file to its final location
std::fs::rename(temp_file, dest_file)?;
Ok(())
}
}
#[derive(serde::Deserialize)]
@ -258,7 +325,7 @@ struct QCowInfoOutput {
}
/// Get QCow2 virtual size
fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
// Run qemu-img
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.args([
@ -281,5 +348,5 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
// Decode JSON
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
Ok(decoded.virtual_size)
Ok(FileSize::from_bytes(decoded.virtual_size))
}

View File

@ -0,0 +1,37 @@
#[derive(
serde::Serialize,
serde::Deserialize,
Copy,
Clone,
Debug,
Eq,
PartialEq,
PartialOrd,
Ord,
Default,
)]
pub struct FileSize(usize);
impl FileSize {
pub const fn from_bytes(size: usize) -> Self {
Self(size)
}
pub const fn from_mb(mb: usize) -> Self {
Self(mb * 1000 * 1000)
}
pub const fn from_gb(gb: usize) -> Self {
Self(gb * 1000 * 1000 * 1000)
}
/// Get file size as bytes
pub fn as_bytes(&self) -> usize {
self.0
}
/// Get file size as megabytes
pub fn as_mb(&self) -> usize {
self.0 / (1000 * 1000)
}
}

View File

@ -1,7 +1,9 @@
pub mod exec_utils;
pub mod file_disks_utils;
pub mod file_size_utils;
pub mod files_utils;
pub mod net_utils;
pub mod rand_utils;
pub mod time_utils;
pub mod url_utils;
pub mod vm_file_disks_utils;

View File

@ -0,0 +1,146 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::libvirt_lib_structures::XMLUuid;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::file_size_utils::FileSize;
use crate::utils::files_utils;
use lazy_regex::regex;
use std::path::{Path, PathBuf};
#[derive(thiserror::Error, Debug)]
enum VMDisksError {
#[error("DiskConfigError: {0}")]
Config(&'static str),
}
/// Type of disk allocation
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
pub enum VMDiskAllocType {
Fixed,
Sparse,
}
/// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "format")]
pub enum VMDiskFormat {
Raw {
/// Type of disk allocation
alloc_type: VMDiskAllocType,
},
QCow2,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct VMFileDisk {
/// Disk name
pub name: String,
/// Disk size, in bytes
pub size: FileSize,
/// Disk format
#[serde(flatten)]
pub format: VMDiskFormat,
/// Set this variable to true to delete the disk
pub delete: bool,
}
impl VMFileDisk {
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
let file = Path::new(path);
let info = DiskFileInfo::load_file(file)?;
Ok(Self {
name: info.name,
// Get only the virtual size of the file
size: match info.format {
DiskFileFormat::Raw { .. } => info.file_size,
DiskFileFormat::QCow2 { virtual_size } => virtual_size,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
format: match info.format {
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw {
alloc_type: match is_sparse {
true => VMDiskAllocType::Sparse,
false => VMDiskAllocType::Fixed,
},
},
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
delete: false,
})
}
pub fn check_config(&self) -> anyhow::Result<()> {
if constants::DISK_NAME_MIN_LEN > self.name.len()
|| constants::DISK_NAME_MAX_LEN < self.name.len()
{
return Err(VMDisksError::Config("Disk name length is invalid").into());
}
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
return Err(VMDisksError::Config("Disk name contains invalid characters!").into());
}
// Check disk size
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
return Err(VMDisksError::Config("Disk size is invalid!").into());
}
Ok(())
}
/// Get disk path
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id);
let file_name = match self.format {
VMDiskFormat::Raw { .. } => self.name.to_string(),
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
};
domain_dir.join(&file_name)
}
/// Apply disk configuration
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
self.check_config()?;
let file = self.disk_path(id);
files_utils::create_directory_if_missing(file.parent().unwrap())?;
// Delete file if requested
if self.delete {
if !file.exists() {
log::debug!("File {file:?} does not exists, so it was not deleted");
return Ok(());
}
log::info!("Deleting {file:?}");
std::fs::remove_file(file)?;
return Ok(());
}
if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched");
return Ok(());
}
// Create disk file
DiskFileInfo::create(
&file,
match self.format {
VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw {
is_sparse: alloc_type == VMDiskAllocType::Sparse,
},
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size,
},
},
self.size,
)?;
Ok(())
}
}

View File

@ -34,7 +34,7 @@ docker compose up
sudo mkdir /var/virtweb
sudo chown $USER:$USER /var/virtweb
cd virtweb_backend
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system"
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173"
```
7. Run the frontend

View File

@ -1,16 +1,17 @@
import { APIClient } from "./ApiClient";
export type DiskImageFormat =
| { format: "Raw"; is_sparse: boolean }
| { format: "QCow2"; virtual_size?: number }
| { format: "CompressedQCow2" }
| { format: "CompressedRaw" };
export type DiskImage = {
file_size: number;
file_name: string;
name: string;
created: number;
} & (
| { format: "Raw"; is_sparse: boolean }
| { format: "QCow2"; virtual_size: number }
| { format: "CompressedQCow2" }
| { format: "CompressedRaw" }
);
} & DiskImageFormat;
export class DiskImageApi {
/**
@ -42,4 +43,47 @@ export class DiskImageApi {
})
).data;
}
/**
* Download disk image file
*/
static async Download(
file: DiskImage,
progress: (p: number) => void
): Promise<Blob> {
return (
await APIClient.exec({
method: "GET",
uri: `/disk_images/${file.file_name}`,
downProgress(e) {
progress(Math.floor(100 * (e.progress / e.total)));
},
})
).data;
}
/**
* Convert disk image file
*/
static async Convert(
file: DiskImage,
dest_file_name: string,
dest_format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/disk_images/${file.file_name}/convert`,
jsonData: { ...dest_format, dest_file_name },
});
}
/**
* Delete disk image file
*/
static async Delete(file: DiskImage): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/disk_images/${file.file_name}`,
});
}
}

View File

@ -22,6 +22,7 @@ export interface ServerConstraints {
memory_size: LenConstraint;
disk_name_size: LenConstraint;
disk_size: LenConstraint;
disk_image_name_size: LenConstraint;
net_name_size: LenConstraint;
net_title_size: LenConstraint;
net_nat_comment_size: LenConstraint;

View File

@ -0,0 +1,123 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React from "react";
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
import { SelectInput } from "../widgets/forms/SelectInput";
import { TextInput } from "../widgets/forms/TextInput";
export function ConvertDiskImageDialog(p: {
image: DiskImage;
onCancel: () => void;
onFinished: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const [format, setFormat] = React.useState<DiskImageFormat>({
format: "QCow2",
});
const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2");
const handleFormatChange = (value?: string) => {
setFormat({ format: value ?? ("QCow2" as any) });
if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`);
if (value === "CompressedQCow2")
setFilename(`${p.image.file_name}.qcow2.gz`);
if (value === "Raw") {
setFilename(`${p.image.file_name}.raw`);
// Check sparse checkbox by default
setFormat({ format: "Raw", is_sparse: true });
}
if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`);
};
const handleSubmit = async () => {
try {
loadingMessage.show("Converting image...");
// Perform the conversion
await DiskImageApi.Convert(p.image, filename, format);
p.onFinished();
snackbar("Conversion successful!");
} catch (e) {
console.error("Failed to convert image!", e);
alert(`Failed to convert image! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<Dialog open onClose={p.onCancel}>
<DialogTitle>Convert disk image</DialogTitle>
<DialogContent>
<DialogContentText>
Select the destination format for this image:
</DialogContentText>
<FileDiskImageWidget image={p.image} />
{/* New image format */}
<SelectInput
editable
label="Target format"
value={format.format}
onValueChange={handleFormatChange}
options={[
{ value: "QCow2" },
{ value: "Raw" },
{ value: "CompressedRaw" },
{ value: "CompressedQCow2" },
]}
/>
{/* Check for sparse file */}
{format.format === "Raw" && (
<CheckboxInput
editable
label="Sparse file"
checked={format.is_sparse}
onValueChange={(c) => {
setFormat({ format: "Raw", is_sparse: c });
}}
/>
)}
{/* New image name */}
<TextInput
editable
label="New image name"
value={filename}
onValueChange={(s) => {
setFilename(s ?? "");
}}
size={ServerApi.Config.constraints.disk_image_name_size}
helperText="The image name shall contain the proper file extension"
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} autoFocus>
Convert image
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -1,18 +1,29 @@
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import LoopIcon from "@mui/icons-material/Loop";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Alert,
Button,
CircularProgress,
IconButton,
LinearProgress,
Tooltip,
Typography,
} from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { filesize } from "filesize";
import React from "react";
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { downloadBlob } from "../utils/FilesUtils";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget";
import { FileInput } from "../widgets/forms/FileInput";
import { VirtWebPaper } from "../widgets/VirtWebPaper";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
@ -32,28 +43,28 @@ export function DiskImagesRoute(): React.ReactElement {
};
return (
<VirtWebRouteContainer label="Disk images">
<VirtWebRouteContainer
label="Disk images management"
actions={
<span>
<Tooltip title="Refresh Disk images list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<AsyncWidget
loadKey={loadKey.current}
errMsg="Failed to load disk images list!"
load={load}
ready={list !== undefined}
build={() => (
<VirtWebRouteContainer
label="Disk images management"
actions={
<span>
<Tooltip title="Refresh Disk images list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<>
<UploadDiskImageCard onFileUploaded={reload} />
<DiskImageList list={list!} onReload={reload} />
</VirtWebRouteContainer>
</>
)}
/>
</VirtWebRouteContainer>
@ -148,5 +159,175 @@ function DiskImageList(p: {
list: DiskImage[];
onReload: () => void;
}): React.ReactElement {
return <>todo</>;
const alert = useAlert();
const snackbar = useSnackbar();
const confirm = useConfirm();
const loadingMessage = useLoadingMessage();
const [currConversion, setCurrConversion] = React.useState<
DiskImage | undefined
>();
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
// Convert disk image file
const convertDiskImage = (entry: DiskImage) => {
setCurrConversion(entry);
};
// Download disk image file
const downloadDiskImage = async (entry: DiskImage) => {
setDlProgress(0);
try {
const blob = await DiskImageApi.Download(entry, setDlProgress);
downloadBlob(blob, entry.file_name);
} catch (e) {
console.error(e);
alert(`Failed to download disk image file! ${e}`);
}
setDlProgress(undefined);
};
// Delete disk image
const deleteDiskImage = async (entry: DiskImage) => {
if (
!(await confirm(
`Do you really want to delete this disk image (${entry.file_name}) ?`
))
)
return;
loadingMessage.show("Deleting disk image file...");
try {
await DiskImageApi.Delete(entry);
snackbar("The disk image has been successfully deleted!");
p.onReload();
} catch (e) {
console.error(e);
alert(`Failed to delete disk image!\n${e}`);
}
loadingMessage.hide();
};
if (p.list.length === 0)
return (
<Typography variant="body1" style={{ textAlign: "center" }}>
No disk image uploaded for now.
</Typography>
);
const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "file_name", headerName: "File name", flex: 3 },
{
field: "format",
headerName: "Format",
flex: 1,
renderCell(params) {
let content = params.row.format;
if (params.row.format === "Raw") {
content += params.row.is_sparse ? " (Sparse)" : " (Fixed)";
}
return content;
},
},
{
field: "file_size",
headerName: "File size",
flex: 1,
renderCell(params) {
let res = filesize(params.row.file_size);
if (params.row.format === "QCow2") {
res += ` (${filesize(params.row.virtual_size!)})`;
}
return res;
},
},
{
field: "created",
headerName: "Created",
flex: 1,
renderCell(params) {
return <DateWidget time={params.row.created} />;
},
},
{
field: "actions",
headerName: "",
width: 140,
renderCell(params) {
return (
<>
<Tooltip title="Convert disk image">
<IconButton onClick={() => { convertDiskImage(params.row); }}>
<LoopIcon />
</IconButton>
</Tooltip>
<Tooltip title="Download disk image">
<IconButton onClick={() => downloadDiskImage(params.row)}>
<DownloadIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete disk image">
<IconButton onClick={() => deleteDiskImage(params.row)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</>
);
},
},
];
return (
<>
{/* Download notification */}
{dlProgress !== undefined && (
<Alert severity="info">
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
overflow: "hidden",
}}
>
<Typography variant="body1">
Downloading... {dlProgress}%
</Typography>
<CircularProgress
variant="determinate"
size={"1.5rem"}
style={{ marginLeft: "10px" }}
value={dlProgress}
/>
</div>
</Alert>
)}
{/* Disk image conversion dialog */}
{currConversion && (
<ConvertDiskImageDialog
image={currConversion}
onCancel={() => {
setCurrConversion(undefined);
}}
onFinished={() => {
setCurrConversion(undefined);
p.onReload();
}}
/>
)}
{/* The table itself */}
<DataGrid getRowId={(c) => c.file_name} rows={p.list} columns={columns} />
</>
);
}

View File

@ -0,0 +1,13 @@
export function DateWidget(p: { time: number }): React.ReactElement {
const date = new Date(p.time * 1000);
return (
<>
{pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()}
</>
);
}
function pad(num: number): string {
return num.toString().padStart(2, "0");
}

View File

@ -0,0 +1,23 @@
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
import { DiskImage } from "../api/DiskImageApi";
import { mdiHarddisk } from "@mdi/js";
import { filesize } from "filesize";
import Icon from "@mdi/react";
export function FileDiskImageWidget(p: {
image: DiskImage;
}): React.ReactElement {
return (
<ListItem>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={p.image.file_name}
secondary={`${p.image.format} - ${filesize(p.image.file_size)}`}
/>
</ListItem>
);
}