Files
VirtWeb/virtweb_backend/src/utils/file_disks_utils.rs
Pierre HUBERT 9bd702d60f
All checks were successful
continuous-integration/drone/push Build is passing
Add cloud-localds as a required program
2025-06-06 17:19:11 +02:00

367 lines
12 KiB
Rust

use crate::app_config::AppConfig;
use crate::constants;
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;
use std::time::UNIX_EPOCH;
#[derive(thiserror::Error, Debug)]
enum DisksError {
#[error("DiskParseError: {0}")]
Parse(&'static str),
#[error("DiskCreateError")]
Create,
#[error("DiskConvertError: {0}")]
Convert(String),
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(tag = "format")]
pub enum DiskFileFormat {
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 {
pub file_path: PathBuf,
pub file_size: FileSize,
#[serde(flatten)]
pub format: DiskFileFormat,
pub file_name: String,
pub name: String,
pub created: u64,
}
impl DiskFileInfo {
/// Get disk image file information
pub fn load_file(file: &Path) -> anyhow::Result<Self> {
if !file.is_file() {
return Err(DisksError::Parse("Path is not a file!").into());
}
// Get file metadata
let metadata = file.metadata()?;
let mut name = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("disk")
.to_string();
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
// Determine file format
let format = match ext {
"qcow2" => DiskFileFormat::QCow2 {
virtual_size: qcow_virt_size(file)?,
},
"raw" => DiskFileFormat::Raw {
is_sparse: metadata.len() / 512 >= metadata.st_blocks(),
},
"gz" if name.ends_with(".qcow2") => {
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
DiskFileFormat::CompressedQCow2
}
"gz" => DiskFileFormat::CompressedRaw,
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
};
Ok(Self {
file_path: file.to_path_buf(),
name,
file_size: FileSize::from_bytes(metadata.len() as usize),
format,
file_name: file
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string(),
created: metadata
.created()?
.duration_since(UNIX_EPOCH)
.unwrap()
.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::PROGRAM_QEMU_IMAGE);
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::PROGRAM_GZIP);
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::PROGRAM_GZIP);
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::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"]);
}
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::PROGRAM_QEMU_IMAGE);
cmd.arg("convert")
.arg("-f")
.arg("qcow2")
.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::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::PROGRAM_COPY);
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::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::PROGRAM_GZIP);
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::PROGRAM_GZIP);
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::PROGRAM_BASH);
cmd.arg("-c").arg(format!(
"{} -d -c {} | {} conv=sparse of={}",
constants::PROGRAM_GZIP,
self.file_path.display(),
constants::PROGRAM_DD,
temp_file.display()
));
cmd
}
// Dumb copy of file
(a, b) if a == b => {
let mut cmd = Command::new(constants::PROGRAM_COPY);
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)]
struct QCowInfoOutput {
#[serde(rename = "virtual-size")]
virtual_size: usize,
}
/// Get QCow2 virtual size
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
// Run qemu-img
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
cmd.args([
"info",
path.to_str().unwrap_or(""),
"--output",
"json",
"--force-share",
]);
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)
);
}
let res_json = String::from_utf8(output.stdout)?;
// Decode JSON
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
Ok(FileSize::from_bytes(decoded.virtual_size))
}