Albatross/src/backup.rs

478 lines
15 KiB
Rust

use crate::config::{
AlbatrossConfig, RemoteBackupConfig, World26PlusConfig, WorldConfig, WorldType,
};
use crate::discord::send_webhook;
use crate::error::{AlbatrossError, Result};
use crate::region::Region;
use crate::remote::RemoteBackupSite;
use crate::remote::file::FileBackup;
use crate::remote::ftp::FTPBackup;
use crate::remote::sftp::SFTPBackup;
use chrono::Utc;
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use log::{error, info, warn};
use std::convert::TryFrom;
use std::fs::{File, copy, create_dir, create_dir_all, remove_dir_all, rename};
use std::path::{Path, PathBuf};
use std::time::Instant;
use tar::Archive;
/// Backup a file
///
/// # Param
/// * `file_name` - file name
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
pub fn backup_file(file_name: &str, world_path: &Path, backup_path: &Path) -> Result<u64> {
let world_path = world_path.join(file_name);
let backup_path = backup_path.join(file_name);
if !world_path.exists() {
warn!("File '{world_path:?}' does not exist.");
}
Ok(copy(world_path, backup_path)?)
}
/// Backup a directory
///
/// # Param
/// * `dir_name` - directory name
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
pub fn backup_dir(dir_name: &str, world_path: &Path, backup_path: &Path) -> Result<u64> {
let src_dir = world_path.join(dir_name);
if !src_dir.exists() || !src_dir.is_dir() {
warn!("Directory '{dir_name}' does not exist in '{world_path:?}'");
return Ok(0);
}
let backup_dir_path = backup_path.join(dir_name);
create_dir(&backup_dir_path)?;
let mut file_count = 0;
for entry in src_dir.read_dir()? {
let entry = entry?;
let mut target = backup_dir_path.clone();
if entry.path().is_dir() {
let sub_dir = entry.file_name();
let world_path = world_path.join(dir_name);
let backup_path = backup_path.join(dir_name);
if !backup_path.exists() {
create_dir(&backup_path)?;
}
file_count += backup_dir(sub_dir.to_str().unwrap(), &world_path, &backup_path)?;
} else {
target.push(entry.file_name());
copy(entry.path(), target)?;
file_count += 1;
}
}
Ok(file_count)
}
/// Backup the regions
///
/// # Param
/// * `dir_name` - name of the backup folder
/// * `save_radius` - block radius to save
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
pub fn backup_region(
dir_name: &str,
save_radius: u64,
world_path: &Path,
backup_path: &Path,
) -> Result<u64> {
let mut count: u64 = 0;
let src_dir = world_path.join(dir_name);
if !src_dir.exists() {
warn!("Region directory '{dir_name}' does not exist in '{world_path:?}'");
return Ok(0);
}
let backup_dir = backup_path.join(dir_name);
create_dir(&backup_dir)?;
let save_radius = (save_radius as f64 / 512.0).ceil() as i64;
for entry in src_dir.read_dir()? {
let entry = entry?;
let file_name = entry.file_name().to_str().unwrap().to_string();
if let Ok(region) = Region::try_from(file_name)
&& region.x.abs() <= save_radius
&& region.y.abs() <= save_radius
{
let mut target = backup_dir.clone();
target.push(entry.file_name());
copy(entry.path(), target)?;
count += 1;
}
}
Ok(count)
}
/// Backup a world
///
/// # Param
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_world(
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64> {
let backup_path = backup_path.join(&world_config.world_name);
create_dir(backup_path.as_path())?;
backup_region("poi", world_config.save_radius, world_path, &backup_path)?;
let region_count = backup_region("region", world_config.save_radius, world_path, &backup_path)?;
Ok(region_count)
}
/// Backup the overworld
///
/// # Param
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_overworld(
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<(u64, u64)> {
backup_dir("data", world_path, backup_path)?;
backup_dir("stats", world_path, backup_path).ok();
backup_file("level.dat", world_path, backup_path)?;
backup_file("level.dat_old", world_path, backup_path).ok();
backup_file("session.lock", world_path, backup_path).ok();
backup_file("uid.dat", world_path, backup_path)?;
let player_count = backup_dir("playerdata", world_path, backup_path)?;
let region_count = backup_world(world_path, backup_path, world_config)?;
Ok((region_count, player_count))
}
/// Backup the nether
///
/// # Param
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_nether(
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64> {
let nether_path = world_path.join(WorldType::Nether.dim_name());
backup_world(&nether_path, backup_path, world_config)
}
/// Backup the end
///
/// # Param
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_end(
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64> {
let end_path = world_path.join(WorldType::End.dim_name());
backup_world(&end_path, backup_path, world_config)
}
/// Compress the backup after the files have been copied
///
/// # Param
/// * `tmp_dir`: tmp directory with the backed up files
/// * `output_file`: output archive
pub fn compress_backup(tmp_dir: &Path, output_file: &Path) -> Result<()> {
let archive = File::create(output_file)?;
let enc = GzEncoder::new(archive, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
tar_builder.append_dir_all(".", tmp_dir)?;
Ok(())
}
pub fn uncompress_backup(backup: &Path) -> Result<PathBuf> {
let backup_file = File::open(backup)?;
let dec = GzDecoder::new(backup_file);
let mut extract = Archive::new(dec);
let extract_path = PathBuf::from("tmp");
extract.unpack(&extract_path)?;
Ok(extract_path)
}
/// Takes an existing backup and converts it to a singleplayer world
///
/// # Param
/// * config - Albatross config
/// * backup - path of the backup to convert
/// * output - output path
pub fn convert_backup_to_sp(config: &AlbatrossConfig, backup: &Path, output: &Path) -> Result<()> {
let extract_path = uncompress_backup(backup)?;
if let Some(worlds) = &config.world_config {
for world in worlds {
let world_type = world.world_type.clone().unwrap_or(WorldType::Overworld);
let src = PathBuf::from(&extract_path).join(&world.world_name);
let dest = PathBuf::from(&extract_path);
match world_type {
WorldType::Overworld => {
rename(src.clone().join("poi"), dest.clone().join("poi"))?;
rename(src.clone().join("region"), dest.clone().join("region"))?;
}
WorldType::Nether => {
rename(src, dest.clone().join("DIM-1"))?;
}
WorldType::End => {
rename(src, dest.clone().join("DIM1"))?;
}
}
}
}
compress_backup(&extract_path, output)?;
remove_dir_all(&extract_path)?;
Ok(())
}
/// Preform a remote_backup backup, if configured
pub fn do_remote_backup(
remote_backup_cfg: &RemoteBackupConfig,
backup_path: PathBuf,
) -> Result<()> {
if let Some(config) = &remote_backup_cfg.sftp {
let mut sftp_backup = SFTPBackup::new(config, remote_backup_cfg.backups_to_keep)?;
sftp_backup.backup_to_remote(backup_path)?;
sftp_backup.cleanup()?;
} else if let Some(config) = &remote_backup_cfg.ftp {
let mut ftps_backup = FTPBackup::new(config, remote_backup_cfg.backups_to_keep)?;
ftps_backup.backup_to_remote(backup_path)?;
ftps_backup.cleanup()?;
} else if let Some(config) = &remote_backup_cfg.file {
let mut file_backup = FileBackup::new(config, remote_backup_cfg.backups_to_keep)?;
file_backup.backup_to_remote(backup_path)?;
file_backup.cleanup()?;
}
Ok(())
}
/// Backup the configured worlds from a minecraft server
///
/// # Params
/// * `cfg` - config file
pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> {
let server_base_dir = cfg.backup.minecraft_dir.clone();
let timer = Instant::now();
let time_str = Utc::now().format("%d-%m-%y_%H.%M.%S").to_string();
let backup_name = format!("{time_str}_backup.tar.gz");
let mut output_archive = match output {
Some(out_path) => out_path,
None => cfg.backup.output_config.path.clone(),
};
output_archive.push(backup_name);
let mut tmp_dir = cfg.backup.output_config.path.clone();
tmp_dir.push("tmp");
remove_dir_all(&tmp_dir).ok();
create_dir_all(tmp_dir.clone())?;
send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg);
let backup_res = if let Some(worlds) = &cfg.world_config {
backup_worlds(&cfg, server_base_dir, worlds, &tmp_dir)
} else if let Some(world_config) = &cfg.world_26_plus_config {
backup_v26_plus_world_format(&cfg, world_config, &tmp_dir)
} else {
Err(AlbatrossError::MissingConfig)
};
if let Err(err) = backup_res {
send_webhook("Failed to backup worlds", &cfg);
error!("Failed to backup worlds: {err}");
return Err(err);
}
compress_backup(&tmp_dir, &output_archive).map_err(|e| {
send_webhook("Failed to compress backup", &cfg);
error!("Failed to compress backup: {e}");
e
})?;
remove_dir_all(&tmp_dir)?;
let mut local_backup = FileBackup::new(&cfg.backup.output_config, cfg.backup.backups_to_keep)?;
match local_backup.cleanup() {
Ok(backups_removed) => {
if backups_removed > 0 {
let msg = format!(
"Albatross mistook **{backups_removed}** of your old backups for some french fries and ate them!! SKRAWWWW"
);
send_webhook(msg.as_str(), &cfg);
info!("Removing {backups_removed} backups...")
}
}
Err(e) => {
send_webhook("Failed to remove old backups!", &cfg);
error!("Failed to remove old backups: {e}")
}
}
if let Some(remote_backup_config) = &cfg.remote {
match do_remote_backup(remote_backup_config, output_archive) {
Ok(_) => {
send_webhook("Remote backup completed!", &cfg);
}
Err(e) => {
send_webhook("Remote backup failed!", &cfg);
error!("Remote backup failed with error: {e}");
}
}
}
let secs = timer.elapsed().as_secs();
send_webhook(
format!("**Full backup completed in {secs}s**! *SKREEEEEEEEEE*").as_str(),
&cfg,
);
info!("Full backup completed in {secs}s!");
Ok(())
}
/// Backup a dimension
///
/// # Param
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_dimension(
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64> {
let dimension_path = Path::new("dimensions").join(&world_config.world_name);
let backup_path = backup_path.join(&dimension_path);
let src_path = world_path.join(dimension_path);
create_dir_all(backup_path.as_path())?;
backup_dir("data", &src_path, &backup_path)?;
let region_count = backup_region("region", world_config.save_radius, &src_path, &backup_path)?;
Ok(region_count)
}
/// Backup v26.1 + version
///
/// # Param
/// * `world_path` - path to the world folder
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_v26_plus_world_format(
cfg: &AlbatrossConfig,
world_config: &World26PlusConfig,
tmp_dir: &Path,
) -> Result<()> {
let world_path = cfg.backup.minecraft_dir.join(&world_config.world_name);
let backup_path = tmp_dir.join(&world_config.world_name);
if !backup_path.exists() {
create_dir(&backup_path)?;
}
send_webhook(
format!("Starting backup **{}**", world_config.world_name).as_str(),
cfg,
);
// Backup common files
backup_dir("data", &world_path, &backup_path)?;
backup_dir("datapacks", &world_path, &backup_path)?;
backup_file("level.dat", &world_path, &backup_path)?;
backup_file("level.dat_old", &world_path, &backup_path).ok();
backup_file("session.lock", &world_path, &backup_path).ok();
let player_count = backup_dir("players", &world_path, &backup_path)?;
send_webhook(format!("Backed up {player_count} players").as_str(), cfg);
info!("Backed up {player_count} players");
for world_config in &world_config.dimensions {
send_webhook(
format!("Starting backup of **{}**", world_config.world_name).as_str(),
cfg,
);
info!(
"Starting backup of dimension **{}**",
world_config.world_name
);
let region_count = backup_dimension(&world_path, &backup_path, world_config)?;
send_webhook(format!("{region_count} regions backed up.").as_str(), cfg);
info!("{region_count} regions backed up.")
}
Ok(())
}
fn backup_worlds(
cfg: &AlbatrossConfig,
server_base_dir: PathBuf,
worlds: &[WorldConfig],
tmp_dir: &Path,
) -> Result<()> {
for world in worlds {
let mut world_dir = server_base_dir.clone();
let world_name = world.world_name.clone();
let world_type = world.world_type.clone().unwrap_or(WorldType::Overworld);
world_dir.push(world_name.clone());
if world_dir.exists() && world_dir.is_dir() {
send_webhook(format!("Starting backup of **{world_name}**").as_str(), cfg);
info!("Starting backup of {world_name}.");
let webhook_msg = match world_type {
WorldType::Overworld => {
let (region_count, player_count) =
backup_overworld(&world_dir.clone(), tmp_dir, world)?;
format!("{region_count} regions and {player_count} player files backed up.")
}
WorldType::Nether => {
let region_count = backup_nether(&world_dir, tmp_dir, world)?;
format!("{region_count} regions backed up.")
}
WorldType::End => {
let region_count = backup_end(&world_dir, tmp_dir, world)?;
format!("{region_count} regions backed up.")
}
};
send_webhook(&webhook_msg, cfg);
info!("{webhook_msg}");
} else {
send_webhook(format!("Error: {world_name} not found.").as_str(), cfg);
error!("World \"{world_name}\" not found");
}
}
Ok(())
}