All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			
		
			
				
	
	
		
			469 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			469 lines
		
	
	
		
			16 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,
 | 
						|
    },
 | 
						|
    GzCompressedRaw,
 | 
						|
    GzCompressedQCow2,
 | 
						|
    XzCompressedRaw,
 | 
						|
    XzCompressedQCow2,
 | 
						|
}
 | 
						|
 | 
						|
impl DiskFileFormat {
 | 
						|
    pub fn ext(&self) -> &'static [&'static str] {
 | 
						|
        match self {
 | 
						|
            DiskFileFormat::Raw { .. } => &["raw", ""],
 | 
						|
            DiskFileFormat::QCow2 { .. } => &["qcow2"],
 | 
						|
            DiskFileFormat::GzCompressedRaw => &["raw.gz"],
 | 
						|
            DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"],
 | 
						|
            DiskFileFormat::XzCompressedRaw => &["raw.xz"],
 | 
						|
            DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"],
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/// 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::GzCompressedQCow2
 | 
						|
            }
 | 
						|
            "gz" => DiskFileFormat::GzCompressedRaw,
 | 
						|
            "xz" if name.ends_with(".qcow2") => {
 | 
						|
                name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
 | 
						|
                DiskFileFormat::XzCompressedQCow2
 | 
						|
            }
 | 
						|
            "xz" => DiskFileFormat::XzCompressedRaw,
 | 
						|
            _ => 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 (GZIP)
 | 
						|
            (DiskFileFormat::GzCompressedQCow2, 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
 | 
						|
            }
 | 
						|
 | 
						|
            // Decompress QCow2 (XZ)
 | 
						|
            (DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
						|
                cmd.arg("--stdout")
 | 
						|
                    .arg("--keep")
 | 
						|
                    .arg("--decompress")
 | 
						|
                    .arg(&self.file_path)
 | 
						|
                    .stdout(File::create(&temp_file)?);
 | 
						|
                cmd
 | 
						|
            }
 | 
						|
 | 
						|
            // Compress QCow2 (Gzip)
 | 
						|
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_GZIP);
 | 
						|
                cmd.arg("--keep")
 | 
						|
                    .arg("--to-stdout")
 | 
						|
                    .arg(&self.file_path)
 | 
						|
                    .stdout(File::create(&temp_file)?);
 | 
						|
                cmd
 | 
						|
            }
 | 
						|
 | 
						|
            // Compress QCow2 (Xz)
 | 
						|
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
						|
                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 (Gz)
 | 
						|
            (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_GZIP);
 | 
						|
                cmd.arg("--keep")
 | 
						|
                    .arg("--to-stdout")
 | 
						|
                    .arg(&self.file_path)
 | 
						|
                    .stdout(File::create(&temp_file)?);
 | 
						|
                cmd
 | 
						|
            }
 | 
						|
 | 
						|
            // Compress Raw (Xz)
 | 
						|
            (DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
						|
                cmd.arg("--keep")
 | 
						|
                    .arg("--to-stdout")
 | 
						|
                    .arg(&self.file_path)
 | 
						|
                    .stdout(File::create(&temp_file)?);
 | 
						|
                cmd
 | 
						|
            }
 | 
						|
 | 
						|
            // Decompress Raw (Gz) to not sparse file
 | 
						|
            (DiskFileFormat::GzCompressedRaw, 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 (Xz) to not sparse file
 | 
						|
            (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
						|
                cmd.arg("--keep")
 | 
						|
                    .arg("--decompress")
 | 
						|
                    .arg("--to-stdout")
 | 
						|
                    .arg(&self.file_path)
 | 
						|
                    .stdout(File::create(&temp_file)?);
 | 
						|
                cmd
 | 
						|
            }
 | 
						|
 | 
						|
            // Decompress Raw (Gz) to sparse file
 | 
						|
            // https://benou.fr/www/ben/decompressing-sparse-files.html
 | 
						|
            (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_BASH);
 | 
						|
                cmd.arg("-c").arg(format!(
 | 
						|
                    "{} --decompress --to-stdout {} | {} conv=sparse of={}",
 | 
						|
                    constants::PROGRAM_GZIP,
 | 
						|
                    self.file_path.display(),
 | 
						|
                    constants::PROGRAM_DD,
 | 
						|
                    temp_file.display()
 | 
						|
                ));
 | 
						|
                cmd
 | 
						|
            }
 | 
						|
 | 
						|
            // Decompress Raw (XZ) to sparse file
 | 
						|
            // https://benou.fr/www/ben/decompressing-sparse-files.html
 | 
						|
            (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
 | 
						|
                let mut cmd = Command::new(constants::PROGRAM_BASH);
 | 
						|
                cmd.arg("-c").arg(format!(
 | 
						|
                    "{} --decompress --to-stdout {} | {} conv=sparse of={}",
 | 
						|
                    constants::PROGRAM_XZ,
 | 
						|
                    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(())
 | 
						|
    }
 | 
						|
 | 
						|
    /// Get disk virtual size, if available
 | 
						|
    pub fn virtual_size(&self) -> Option<FileSize> {
 | 
						|
        match self.format {
 | 
						|
            DiskFileFormat::Raw { .. } => Some(self.file_size),
 | 
						|
            DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size),
 | 
						|
            _ => None,
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// Resize disk
 | 
						|
    pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> {
 | 
						|
        if new_size <= self.virtual_size().unwrap_or(new_size) {
 | 
						|
            anyhow::bail!("Shrinking disk image file is not supported!");
 | 
						|
        }
 | 
						|
 | 
						|
        let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
						|
        cmd.arg("resize")
 | 
						|
            .arg("-f")
 | 
						|
            .arg(match self.format {
 | 
						|
                DiskFileFormat::QCow2 { .. } => "qcow2",
 | 
						|
                DiskFileFormat::Raw { .. } => "raw",
 | 
						|
                f => anyhow::bail!("Unsupported disk format for resize: {f:?}"),
 | 
						|
            })
 | 
						|
            .arg(&self.file_path)
 | 
						|
            .arg(new_size.as_bytes().to_string());
 | 
						|
 | 
						|
        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)
 | 
						|
            );
 | 
						|
        }
 | 
						|
        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))
 | 
						|
}
 |