5 Commits

Author SHA1 Message Date
794d16bdaa Simplify raw disks definition
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 11:15:21 +02:00
a3ac56f849 Replace success snackbar with alert message in convert disk image dialog 2025-05-30 11:05:14 +02:00
6130f37336 Attempt to increase requests timeout 2025-05-30 11:04:38 +02:00
6b6fef5ccc Can backup vm disks as images 2025-05-30 10:55:40 +02:00
83df7e1b20 Prepare UI for disks backups 2025-05-30 10:28:54 +02:00
12 changed files with 228 additions and 85 deletions

View File

@ -1,6 +1,8 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::constants; use crate::constants;
use crate::controllers::HttpResult; use crate::controllers::{HttpResult, LibVirtReq};
use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_rest_structures::vm::VMInfo;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo}; use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::files_utils; use crate::utils::files_utils;
use actix_files::NamedFile; use actix_files::NamedFile;
@ -107,6 +109,61 @@ pub async fn convert(
return Ok(HttpResponse::BadRequest().json("Invalid src file name!")); return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
} }
let src_file_path = AppConfig::get()
.disk_images_storage_path()
.join(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?;
handle_convert_request(src, &req).await
}
#[derive(serde::Deserialize)]
pub struct BackupVMDiskPath {
uid: XMLUuid,
diskid: String,
}
/// Perform disk backup
pub async fn backup_disk(
client: LibVirtReq,
path: web::Path<BackupVMDiskPath>,
req: web::Json<ConvertDiskImageRequest>,
) -> HttpResult {
// Get the VM information
let info = match client.get_single_domain(path.uid).await {
Ok(i) => i,
Err(e) => {
log::error!("Failed to get domain info! {e}");
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
}
};
let vm = VMInfo::from_domain(info)?;
// Load disk information
let Some(disk) = vm
.file_disks
.into_iter()
.find(|disk| disk.name == path.diskid)
else {
return Ok(HttpResponse::NotFound()
.json(format!("Disk {} not found for vm {}", path.diskid, vm.name)));
};
let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!"));
let src_disk = DiskFileInfo::load_file(&src_path)?;
// Perform conversion
handle_convert_request(src_disk, &req).await
}
/// Generic controller code that performs image conversion to create a disk image file
pub async fn handle_convert_request(
src: DiskFileInfo,
req: &ConvertDiskImageRequest,
) -> HttpResult {
// Check destination file
if !files_utils::check_file_name(&req.dest_file_name) { if !files_utils::check_file_name(&req.dest_file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!")); return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
} }
@ -119,12 +176,6 @@ pub async fn convert(
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); 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() let dst_file_path = AppConfig::get()
.disk_images_storage_path() .disk_images_storage_path()
.join(&req.dest_file_name); .join(&req.dest_file_name);
@ -141,7 +192,7 @@ pub async fn convert(
); );
} }
Ok(HttpResponse::Ok().json(src)) Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
} }
/// Delete a disk image /// Delete a disk image

View File

@ -356,6 +356,10 @@ async fn main() -> std::io::Result<()> {
"/api/disk_images/{filename}", "/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete), web::delete().to(disk_images_controller::delete),
) )
.route(
"/api/vm/{uid}/disk/{diskid}/backup",
web::post().to(disk_images_controller::backup_disk),
)
// API tokens controller // API tokens controller
.route( .route(
"/api/token/create", "/api/token/create",

View File

@ -55,7 +55,7 @@ impl FileSize {
} }
}; };
Ok(Self((value as f64).mul(fact) as usize)) Ok(Self((value as f64).mul(fact).ceil() as usize))
} }
/// Get file size as bytes /// Get file size as bytes

View File

@ -13,20 +13,13 @@ enum VMDisksError {
Config(&'static str), 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 /// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "format")] #[serde(tag = "format")]
pub enum VMDiskFormat { pub enum VMDiskFormat {
Raw { Raw {
/// Type of disk allocation /// Is raw file a sparse file?
alloc_type: VMDiskAllocType, is_sparse: bool,
}, },
QCow2, QCow2,
} }
@ -61,12 +54,7 @@ impl VMFileDisk {
}, },
format: match info.format { format: match info.format {
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
alloc_type: match is_sparse {
true => VMDiskAllocType::Sparse,
false => VMDiskAllocType::Fixed,
},
},
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format), _ => anyhow::bail!("Unsupported image format: {:?}", info.format),
}, },
@ -93,7 +81,7 @@ impl VMFileDisk {
Ok(()) Ok(())
} }
/// Get disk path /// Get disk path on file system
pub fn disk_path(&self, id: XMLUuid) -> PathBuf { pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id); let domain_dir = AppConfig::get().vm_storage_path(id);
let file_name = match self.format { let file_name = match self.format {
@ -131,9 +119,7 @@ impl VMFileDisk {
DiskFileInfo::create( DiskFileInfo::create(
&file, &file,
match self.format { match self.format {
VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw { VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
is_sparse: alloc_type == VMDiskAllocType::Sparse,
},
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size, virtual_size: self.size,
}, },

View File

@ -103,6 +103,7 @@ export class APIClient {
body: body, body: body,
headers: headers, headers: headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(50 * 1000 * 1000),
}); });
// Process response // Process response

View File

@ -1,4 +1,5 @@
import { APIClient } from "./ApiClient"; import { APIClient } from "./ApiClient";
import { VMFileDisk, VMInfo } from "./VMApi";
export type DiskImageFormat = export type DiskImageFormat =
| { format: "Raw"; is_sparse: boolean } | { format: "Raw"; is_sparse: boolean }
@ -77,6 +78,22 @@ export class DiskImageApi {
}); });
} }
/**
* Backup VM disk into image disks library
*/
static async BackupVMDisk(
vm: VMInfo,
disk: VMFileDisk,
dest_file_name: string,
format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
method: "POST",
jsonData: { ...format, dest_file_name },
});
}
/** /**
* Delete disk image file * Delete disk image file
*/ */

View File

@ -24,16 +24,14 @@ export interface BaseFileVMDisk {
name: string; name: string;
delete: boolean; delete: boolean;
// application attribute // application attributes
new?: boolean; new?: boolean;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
} }
export type DiskAllocType = "Sparse" | "Fixed";
interface RawVMDisk { interface RawVMDisk {
format: "Raw"; format: "Raw";
alloc_type: DiskAllocType; is_sparse: boolean;
} }
interface QCow2Disk { interface QCow2Disk {

View File

@ -9,56 +9,69 @@ import {
import React from "react"; import React from "react";
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi"; import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi"; import { ServerApi } from "../api/ServerApi";
import { VMFileDisk, VMInfo } from "../api/VMApi";
import { useAlert } from "../hooks/providers/AlertDialogProvider"; import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget"; import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
import { CheckboxInput } from "../widgets/forms/CheckboxInput"; import { CheckboxInput } from "../widgets/forms/CheckboxInput";
import { SelectInput } from "../widgets/forms/SelectInput"; import { SelectInput } from "../widgets/forms/SelectInput";
import { TextInput } from "../widgets/forms/TextInput"; import { TextInput } from "../widgets/forms/TextInput";
import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
export function ConvertDiskImageDialog(p: { export function ConvertDiskImageDialog(
image: DiskImage; p: {
onCancel: () => void; onCancel: () => void;
onFinished: () => void; onFinished: () => void;
}): React.ReactElement { } & (
| { backup?: false; image: DiskImage }
| { backup: true; disk: VMFileDisk; vm: VMInfo }
)
): React.ReactElement {
const alert = useAlert(); const alert = useAlert();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage(); const loadingMessage = useLoadingMessage();
const [format, setFormat] = React.useState<DiskImageFormat>({ const [format, setFormat] = React.useState<DiskImageFormat>({
format: "QCow2", format: "QCow2",
}); });
const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2"); const origFilename = p.backup ? p.disk.name : p.image.file_name;
const [filename, setFilename] = React.useState(origFilename + ".qcow2");
const handleFormatChange = (value?: string) => { const handleFormatChange = (value?: string) => {
setFormat({ format: value ?? ("QCow2" as any) }); setFormat({ format: value ?? ("QCow2" as any) });
if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`); if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
if (value === "CompressedQCow2") if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
setFilename(`${p.image.file_name}.qcow2.gz`);
if (value === "Raw") { if (value === "Raw") {
setFilename(`${p.image.file_name}.raw`); setFilename(`${origFilename}.raw`);
// Check sparse checkbox by default // Check sparse checkbox by default
setFormat({ format: "Raw", is_sparse: true }); setFormat({ format: "Raw", is_sparse: true });
} }
if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`); if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
loadingMessage.show("Converting image..."); loadingMessage.show(
p.backup ? "Performing backup..." : "Converting image..."
);
// Perform the conversion // Perform the conversion / backup operation
await DiskImageApi.Convert(p.image, filename, format); if (p.backup)
await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format);
else await DiskImageApi.Convert(p.image, filename, format);
p.onFinished(); p.onFinished();
snackbar("Conversion successful!"); alert(p.backup ? "Backup successful!" : "Conversion successful!");
} catch (e) { } catch (e) {
console.error("Failed to convert image!", e); console.error("Failed to perform backup/conversion!", e);
alert(`Failed to convert image! ${e}`); alert(
p.backup
? `Failed to perform backup! ${e}`
: `Failed to convert image! ${e}`
);
} finally { } finally {
loadingMessage.hide(); loadingMessage.hide();
} }
@ -66,13 +79,21 @@ export function ConvertDiskImageDialog(p: {
return ( return (
<Dialog open onClose={p.onCancel}> <Dialog open onClose={p.onCancel}>
<DialogTitle>Convert disk image</DialogTitle> <DialogTitle>
{p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
Select the destination format for this image: Select the destination format for this image:
</DialogContentText> </DialogContentText>
<FileDiskImageWidget image={p.image} />
{/* Show details of of the image */}
{p.backup ? (
<VMDiskFileWidget {...p} />
) : (
<FileDiskImageWidget {...p} />
)}
{/* New image format */} {/* New image format */}
<SelectInput <SelectInput
@ -109,13 +130,13 @@ export function ConvertDiskImageDialog(p: {
setFilename(s ?? ""); setFilename(s ?? "");
}} }}
size={ServerApi.Config.constraints.disk_image_name_size} size={ServerApi.Config.constraints.disk_image_name_size}
helperText="The image name shall contain the proper file extension" helperText="The image name shall contain the proper file extension for the selected target format"
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={p.onCancel}>Cancel</Button> <Button onClick={p.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} autoFocus> <Button onClick={handleSubmit} autoFocus>
Convert image {p.backup ? "Perform backup" : "Convert image"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
<VMDetails <VMDetails
vm={p.vm} vm={p.vm}
editable={false} editable={false}
state={state}
screenshot={p.vm.vnc_access && state === "Running"} screenshot={p.vm.vnc_access && state === "Running"}
/> />
</VirtWebRouteContainer> </VirtWebRouteContainer>

View File

@ -1,4 +1,4 @@
import { mdiHarddisk } from "@mdi/js"; import { mdiHarddisk, mdiHarddiskPlus } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
@ -13,17 +13,25 @@ import {
Tooltip, Tooltip,
} from "@mui/material"; } from "@mui/material";
import { filesize } from "filesize"; import { filesize } from "filesize";
import React from "react";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMFileDisk, VMInfo } from "../../api/VMApi"; import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { CheckboxInput } from "./CheckboxInput";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
export function VMDisksList(p: { export function VMDisksList(p: {
vm: VMInfo; vm: VMInfo;
state?: VMState;
onChange?: () => void; onChange?: () => void;
editable: boolean; editable: boolean;
}): React.ReactElement { }): React.ReactElement {
const [currBackupRequest, setCurrBackupRequest] = React.useState<
VMFileDisk | undefined
>();
const addNewDisk = () => { const addNewDisk = () => {
p.vm.file_disks.push({ p.vm.file_disks.push({
format: "QCow2", format: "QCow2",
@ -35,6 +43,14 @@ export function VMDisksList(p: {
p.onChange?.(); p.onChange?.();
}; };
const handleBackupRequest = (disk: VMFileDisk) => {
setCurrBackupRequest(disk);
};
const handleFinishBackup = () => {
setCurrBackupRequest(undefined);
};
return ( return (
<> <>
{/* disks list */} {/* disks list */}
@ -43,25 +59,40 @@ export function VMDisksList(p: {
// eslint-disable-next-line react-x/no-array-index-key // eslint-disable-next-line react-x/no-array-index-key
key={num} key={num}
editable={p.editable} editable={p.editable}
canBackup={!p.editable && !d.new && p.state !== "Running"}
disk={d} disk={d}
onChange={p.onChange} onChange={p.onChange}
removeFromList={() => { removeFromList={() => {
p.vm.file_disks.splice(num, 1); p.vm.file_disks.splice(num, 1);
p.onChange?.(); p.onChange?.();
}} }}
onRequestBackup={handleBackupRequest}
/> />
))} ))}
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
{/* Disk backup */}
{currBackupRequest && (
<ConvertDiskImageDialog
backup
onCancel={handleFinishBackup}
onFinished={handleFinishBackup}
vm={p.vm}
disk={currBackupRequest}
/>
)}
</> </>
); );
} }
function DiskInfo(p: { function DiskInfo(p: {
editable: boolean; editable: boolean;
canBackup: boolean;
disk: VMFileDisk; disk: VMFileDisk;
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
onRequestBackup: (disk: VMFileDisk) => void;
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const deleteDisk = async () => { const deleteDisk = async () => {
@ -88,7 +119,8 @@ function DiskInfo(p: {
return ( return (
<ListItem <ListItem
secondaryAction={ secondaryAction={
p.editable && ( <>
{p.editable && (
<IconButton <IconButton
edge="end" edge="end"
aria-label="delete disk" aria-label="delete disk"
@ -104,7 +136,16 @@ function DiskInfo(p: {
</Tooltip> </Tooltip>
)} )}
</IconButton> </IconButton>
) )}
{p.canBackup && (
<Tooltip title="Backup this disk">
<IconButton onClick={() => p.onRequestBackup(p.disk)}>
<Icon path={mdiHarddiskPlus} size={1} />
</IconButton>
</Tooltip>
)}
</>
} }
> >
<ListItemAvatar> <ListItemAvatar>
@ -126,7 +167,9 @@ function DiskInfo(p: {
</> </>
} }
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${ secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
p.disk.format == "Raw" ? " - " + p.disk.alloc_type : "" p.disk.format == "Raw"
? " - " + (p.disk.is_sparse ? "Sparse" : "Fixed")
: ""
}`} }`}
/> />
</ListItem> </ListItem>
@ -178,21 +221,20 @@ function DiskInfo(p: {
value={p.disk.format} value={p.disk.format}
onValueChange={(v) => { onValueChange={(v) => {
p.disk.format = v as any; p.disk.format = v as any;
if (p.disk.format === "Raw") p.disk.is_sparse = true;
p.onChange?.(); p.onChange?.();
}} }}
/> />
{p.disk.format === "Raw" && ( {p.disk.format === "Raw" && (
<SelectInput <CheckboxInput
editable={true} editable
label="File allocation type" label="Sparse file"
options={[ checked={p.disk.is_sparse}
{ label: "Sparse allocation", value: "Sparse" },
{ label: "Fixed allocation", value: "Fixed" },
]}
value={p.disk.alloc_type}
onValueChange={(v) => { onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.alloc_type = v as any; if (p.disk.format === "Raw") p.disk.is_sparse = v;
p.onChange?.(); p.onChange?.();
}} }}
/> />

View File

@ -10,7 +10,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi"; import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMApi, VMInfo } from "../../api/VMApi"; import { VMApi, VMInfo, VMState } from "../../api/VMApi";
import { useAlert } from "../../hooks/providers/AlertDialogProvider"; import { useAlert } from "../../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { useSnackbar } from "../../hooks/providers/SnackbarProvider"; import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
@ -33,6 +33,7 @@ interface DetailsProps {
editable: boolean; editable: boolean;
onChange?: () => void; onChange?: () => void;
screenshot?: boolean; screenshot?: boolean;
state?: VMState | undefined;
} }
export function VMDetails(p: DetailsProps): React.ReactElement { export function VMDetails(p: DetailsProps): React.ReactElement {

View File

@ -0,0 +1,21 @@
import { mdiHarddisk } from "@mdi/js";
import { Icon } from "@mdi/react";
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
import { filesize } from "filesize";
import { VMFileDisk } from "../../api/VMApi";
export function VMDiskFileWidget(p: { disk: VMFileDisk }): React.ReactElement {
return (
<ListItem>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={p.disk.name}
secondary={`${p.disk.format} - ${filesize(p.disk.size)}`}
/>
</ListItem>
);
}