Can restore disk image when adding disks to virtual machine
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-05-30 14:41:48 +02:00
parent ec9492c933
commit 1d4af8c74e
8 changed files with 132 additions and 40 deletions

View File

@@ -255,6 +255,11 @@ impl AppConfig {
self.storage_path().join("disk_images") self.storage_path().join("disk_images")
} }
/// Get the path of a disk image file
pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
self.disk_images_storage_path().join(name)
}
/// Get VM vnc sockets directory /// Get VM vnc sockets directory
pub fn vnc_sockets_path(&self) -> PathBuf { pub fn vnc_sockets_path(&self) -> PathBuf {
self.storage_path().join("vnc") self.storage_path().join("vnc")

View File

@@ -49,7 +49,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
} }
// Check if a file with the same name already exists // Check if a file with the same name already exists
let dest_path = AppConfig::get().disk_images_storage_path().join(file_name); let dest_path = AppConfig::get().disk_images_file_path(&file_name);
if dest_path.is_file() { if dest_path.is_file() {
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!")); return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
} }
@@ -82,9 +82,7 @@ pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResul
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }
let file_path = AppConfig::get() let file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() { if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
@@ -109,9 +107,7 @@ 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() let src_file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?; let src = DiskFileInfo::load_file(&src_file_path)?;
@@ -176,9 +172,7 @@ pub async fn handle_convert_request(
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
} }
let dst_file_path = AppConfig::get() let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
.disk_images_storage_path()
.join(&req.dest_file_name);
if dst_file_path.exists() { if dst_file_path.exists() {
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!")); return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
@@ -201,9 +195,7 @@ pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }
let file_path = AppConfig::get() let file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() { if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));

View File

@@ -33,6 +33,9 @@ pub struct VMFileDisk {
/// Disk format /// Disk format
#[serde(flatten)] #[serde(flatten)]
pub format: VMDiskFormat, pub format: VMDiskFormat,
/// 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 delete the disk /// Set this variable to true to delete the disk
pub delete: bool, pub delete: bool,
} }
@@ -59,6 +62,7 @@ impl VMFileDisk {
_ => anyhow::bail!("Unsupported image format: {:?}", info.format), _ => anyhow::bail!("Unsupported image format: {:?}", info.format),
}, },
delete: false, delete: false,
from_image: None,
}) })
} }
@@ -78,6 +82,19 @@ impl VMFileDisk {
return Err(VMDisksError::Config("Disk size is invalid!").into()); return Err(VMDisksError::Config("Disk size is invalid!").into());
} }
// Check specified disk image template
if let Some(disk_image) = &self.from_image {
if !files_utils::check_file_name(disk_image) {
return Err(VMDisksError::Config("Disk image template name is not valid!").into());
}
if !AppConfig::get().disk_images_file_path(disk_image).is_file() {
return Err(
VMDisksError::Config("Specified disk image file does not exist!").into(),
);
}
}
Ok(()) Ok(())
} }
@@ -115,17 +132,27 @@ impl VMFileDisk {
return Ok(()); return Ok(());
} }
// Create disk file let format = match self.format {
DiskFileInfo::create( VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
&file, VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
match self.format { virtual_size: self.size,
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.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)?;
}
}
Ok(()) Ok(())
} }

View File

@@ -24,6 +24,9 @@ export interface BaseFileVMDisk {
name: string; name: string;
delete: boolean; delete: boolean;
// For new disk only
from_image?: string;
// application attributes // application attributes
new?: boolean; new?: boolean;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";

View File

@@ -0,0 +1,40 @@
import React from "react";
import { DiskImage } from "../../api/DiskImageApi";
import {
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
} from "@mui/material";
import { FileDiskImageWidget } from "../FileDiskImageWidget";
/**
* Select a disk image
*/
export function DiskImageSelect(p: {
label: string;
value?: string;
onValueChange: (image: string | undefined) => void;
list: DiskImage[];
}): React.ReactElement {
const handleChange = (event: SelectChangeEvent) => {
p.onValueChange(event.target.value);
};
return (
<FormControl fullWidth variant="standard">
<InputLabel>{p.label}</InputLabel>
<Select value={p.value} label={p.label} onChange={handleChange}>
<MenuItem value={undefined}>
<i>None</i>
</MenuItem>
{p.list.map((d) => (
<MenuItem value={d.file_name}>
<FileDiskImageWidget image={d} />
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -17,6 +17,7 @@ export function TextInput(p: {
type?: React.HTMLInputTypeAttribute; type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties; style?: React.CSSProperties;
helperText?: string; helperText?: string;
disabled?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>; if (!p.editable && (p.value ?? "") === "") return <></>;
@@ -35,6 +36,7 @@ export function TextInput(p: {
return ( return (
<TextField <TextField
disabled={p.disabled}
label={p.label} label={p.label}
value={p.value ?? ""} value={p.value ?? ""}
onChange={(e) => onChange={(e) =>

View File

@@ -21,12 +21,15 @@ import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { CheckboxInput } from "./CheckboxInput"; import { CheckboxInput } from "./CheckboxInput";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
import { DiskImageSelect } from "./DiskImageSelect";
import { DiskImage } from "../../api/DiskImageApi";
export function VMDisksList(p: { export function VMDisksList(p: {
vm: VMInfo; vm: VMInfo;
state?: VMState; state?: VMState;
onChange?: () => void; onChange?: () => void;
editable: boolean; editable: boolean;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const [currBackupRequest, setCurrBackupRequest] = React.useState< const [currBackupRequest, setCurrBackupRequest] = React.useState<
VMFileDisk | undefined VMFileDisk | undefined
@@ -67,6 +70,7 @@ export function VMDisksList(p: {
p.onChange?.(); p.onChange?.();
}} }}
onRequestBackup={handleBackupRequest} onRequestBackup={handleBackupRequest}
diskImagesList={p.diskImagesList}
/> />
))} ))}
@@ -93,6 +97,7 @@ function DiskInfo(p: {
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
onRequestBackup: (disk: VMFileDisk) => void; onRequestBackup: (disk: VMFileDisk) => void;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const deleteDisk = async () => { const deleteDisk = async () => {
@@ -198,23 +203,6 @@ function DiskInfo(p: {
</IconButton> </IconButton>
</div> </div>
<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"
/>
<SelectInput <SelectInput
editable={true} editable={true}
label="Disk format" label="Disk format"
@@ -243,6 +231,34 @@ 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}
/>
<DiskImageSelect
label="Use disk image as template"
list={p.diskImagesList}
value={p.disk.from_image}
onValueChange={(v) => {
p.disk.from_image = v;
p.onChange?.();
}}
/>
</Paper> </Paper>
); );
} }

View File

@@ -27,6 +27,7 @@ import { VMDisksList } from "../forms/VMDisksList";
import { VMNetworksList } from "../forms/VMNetworksList"; import { VMNetworksList } from "../forms/VMNetworksList";
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
import { VMScreenshot } from "./VMScreenshot"; import { VMScreenshot } from "./VMScreenshot";
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
interface DetailsProps { interface DetailsProps {
vm: VMInfo; vm: VMInfo;
@@ -38,6 +39,9 @@ interface DetailsProps {
export function VMDetails(p: DetailsProps): React.ReactElement { export function VMDetails(p: DetailsProps): React.ReactElement {
const [groupsList, setGroupsList] = React.useState<string[] | undefined>(); const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
const [diskImagesList, setDiskImagesList] = React.useState<
DiskImage[] | undefined
>();
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>(); const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>(); const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
const [vcpuCombinations, setVCPUCombinations] = React.useState< const [vcpuCombinations, setVCPUCombinations] = React.useState<
@@ -52,6 +56,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
const load = async () => { const load = async () => {
setGroupsList(await GroupApi.GetList()); setGroupsList(await GroupApi.GetList());
setDiskImagesList(await DiskImageApi.GetList());
setIsoList(await IsoFilesApi.GetList()); setIsoList(await IsoFilesApi.GetList());
setBridgesList(await ServerApi.GetNetworksBridgesList()); setBridgesList(await ServerApi.GetNetworksBridgesList());
setVCPUCombinations(await ServerApi.NumberVCPUs()); setVCPUCombinations(await ServerApi.NumberVCPUs());
@@ -67,6 +72,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
build={() => ( build={() => (
<VMDetailsInner <VMDetailsInner
groupsList={groupsList!} groupsList={groupsList!}
diskImagesList={diskImagesList!}
isoList={isoList!} isoList={isoList!}
bridgesList={bridgesList!} bridgesList={bridgesList!}
vcpuCombinations={vcpuCombinations!} vcpuCombinations={vcpuCombinations!}
@@ -90,6 +96,7 @@ enum VMTab {
type DetailsInnerProps = DetailsProps & { type DetailsInnerProps = DetailsProps & {
groupsList: string[]; groupsList: string[];
diskImagesList: DiskImage[];
isoList: IsoFile[]; isoList: IsoFile[];
bridgesList: string[]; bridgesList: string[];
vcpuCombinations: number[]; vcpuCombinations: number[];