From b408ed4d2a1de324e66ca80f0ef6b8f0500fb100 Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sat, 24 Oct 2020 13:41:11 -0500 Subject: [PATCH] Added export subcommand + Takes a backup and exports it as a playable single player world + Changed file name format + Switched to using StructOpt for the argument parsing + Updated README.md --- Cargo.lock | 65 +++++++++++++++- Cargo.toml | 2 +- README.md | 24 ++++++ src/backup.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 208 +++++++++++++------------------------------------- 5 files changed, 331 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de083ed..c28b380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,6 @@ name = "albatross" version = "0.2.0" dependencies = [ "chrono", - "clap", "config", "discord-hooks-rs", "flate2", @@ -43,6 +42,7 @@ dependencies = [ "regex", "reqwest", "serde 1.0.117", + "structopt", "tar", ] @@ -390,6 +390,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "heck" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "hermit-abi" version = "0.1.13" @@ -810,6 +819,30 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "237a5ed80e274dbc66f86bd59c1e25edc039660be53194b5fe0a482e0f2612ea" +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check 0.9.2", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check 0.9.2", +] + [[package]] name = "proc-macro2" version = "1.0.24" @@ -1089,6 +1122,30 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +[[package]] +name = "structopt" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126d630294ec449fae0b16f964e35bf3c74f940da9dca17ee9b905f7b3112eb8" +dependencies = [ + "clap", + "lazy_static 1.4.0", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e51c492f9e23a220534971ff5afc14037289de430e3c83f9daf6a1b6ae91e8" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "1.0.46" @@ -1256,6 +1313,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" + [[package]] name = "unicode-width" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 941c13a..a2142a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = "2.33.0" +structopt = "0.3.20" serde = { version="1.0.116", features=["derive"] } config = "0.9" log = "0.4.8" diff --git a/README.md b/README.md index 54e75cb..e3fc79b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,30 @@ Back up what you care about in your Minecraft worlds. Albatross backs up player files and region files within a certain configurable radius. It can also send Discord webhooks. Backups are compressed and stored as `tar.gz` archives. +## Help +``` +albatross 0.2.0 +Backup your Minecraft Server! + +USAGE: + albatross --config-path + +FLAGS: + -h, --help Prints help information + -V, --version Prints version information + +OPTIONS: + -c, --config-path Path to the Albatross config [env: ALBATROSS_CONFIG=] + +SUBCOMMANDS: + backup Backup a server + export Export a backup as a single player world + help Prints this message or the help of the given subcommand(s) + +Process finished with exit code 1 + +``` + ## Config ```toml [backup] diff --git a/src/backup.rs b/src/backup.rs index ac90faf..92dc9b7 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,10 +1,18 @@ -use crate::config::WorldConfig; +use crate::backup; +use crate::config::{AlbatrossConfig, WorldConfig, WorldType}; +use crate::discord::send_webhook; use crate::region::Region; +use chrono::{NaiveDateTime, Utc}; +use flate2::read::GzDecoder; use flate2::write::GzEncoder; use flate2::Compression; use std::convert::TryFrom; -use std::fs::{copy, create_dir, File}; +use std::fs::{ + copy, create_dir, create_dir_all, remove_dir_all, remove_file, rename, DirEntry, File, +}; use std::path::PathBuf; +use std::time::Instant; +use tar::Archive; /// Backup a file /// @@ -189,3 +197,184 @@ pub fn compress_backup(tmp_dir: &PathBuf, output_file: &PathBuf) -> Result<(), s tar_builder.append_dir_all(".", tmp_dir)?; Ok(()) } + +/// 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: &PathBuf, + output: &PathBuf, +) -> Result<(), std::io::Error> { + 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)?; + + if let Some(worlds) = &config.world_config { + for world in worlds { + let world_type = match world.world_type.clone() { + Some(world_type) => world_type, + None => 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(()) +} + +/// Get the time of the backup from a file name +/// +/// # Param +/// * `archive_entry`: archive entry +fn get_time_from_file_name( + archive_entry: &DirEntry, +) -> Result, std::io::Error> { + let file_name = archive_entry.file_name().to_str().unwrap().to_string(); + let name: Vec<&str> = file_name.split('_').collect(); + + Ok(chrono::NaiveDateTime::parse_from_str(name[0], "%d-%m-%y_%H.%M.%S").ok()) +} + +/// Removes the old backups from the ouput directory +/// +/// # Params +/// * `output_dir` - output directory containing +/// * `keep` - number of backups to keep +fn remove_old_backups(output_dir: &PathBuf, keep: u64) -> Result { + let mut backups = vec![]; + let mut num_of_removed_backups: usize = 0; + + for entry in output_dir.read_dir()? { + let entry = entry?; + + if let Some(ext) = entry.path().extension() { + if ext == "gz" { + backups.push(entry); + } + } + } + + if backups.len() > keep as usize { + backups.sort_by(|a, b| { + let a_time = get_time_from_file_name(a).unwrap().unwrap(); + let b_time = get_time_from_file_name(b).unwrap().unwrap(); + + b_time.cmp(&a_time) + }); + + num_of_removed_backups = backups.len() - keep as usize; + + for _i in 0..num_of_removed_backups { + let oldest = backups.pop().unwrap(); + remove_file(oldest.path())?; + } + } + + Ok(num_of_removed_backups) +} + +/// Backup the configured worlds from a minecraft server +/// +/// # Params +/// * `cfg` - config file +pub fn do_backup(cfg: AlbatrossConfig, output: Option) -> Result<(), std::io::Error> { + let server_base_dir = cfg.backup.minecraft_dir.clone(); + let worlds = cfg.world_config.clone().expect("No worlds configured"); + let time_str = Utc::now().format("%d-%m-%y_%H.%M.%S").to_string(); + let backup_name = format!("{}_backup.tar.gz", time_str); + let mut output_archive = match output { + Some(out_path) => out_path, + None => cfg.backup.output_dir.clone(), + }; + output_archive.push(backup_name); + let mut tmp_dir = cfg.backup.output_dir.clone(); + tmp_dir.push("tmp"); + remove_dir_all(&tmp_dir).ok(); + + create_dir_all(tmp_dir.clone()).unwrap(); + + send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg); + let timer = Instant::now(); + for world in worlds { + let mut world_dir = server_base_dir.clone(); + let world_name = world.world_name.clone(); + let world_type = match world.world_type.clone() { + Some(world_type) => world_type, + None => 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, + ); + let webhook_msg = match world_type { + WorldType::OVERWORLD => { + let (region_count, player_count) = + backup_overworld(world_dir.clone(), tmp_dir.clone(), &world)?; + format!( + "{} regions and {} player files backed up.", + region_count, player_count + ) + } + WorldType::NETHER => { + let region_count = backup_nether(world_dir, tmp_dir.clone(), &world)?; + format!("{} regions backed up.", region_count) + } + WorldType::END => { + let region_count = backup_end(world_dir, tmp_dir.clone(), &world)?; + format!("{} regions backed up.", region_count) + } + }; + + send_webhook(&webhook_msg, &cfg); + } else { + send_webhook(format!("Error: {} not found.", world_name).as_str(), &cfg); + println!("World \"{}\" not found", world_name.clone()); + } + } + + backup::compress_backup(&tmp_dir, &output_archive)?; + + remove_dir_all(&tmp_dir)?; + + let backups_removed = remove_old_backups(&cfg.backup.output_dir, cfg.backup.backups_to_keep)?; + + if backups_removed > 0 { + let msg = format!( + "Albatross mistook **{}** of your old backups for some french fries and ate them!! SKRAWWWW", + backups_removed + ); + send_webhook(msg.as_str(), &cfg); + } + + let secs = timer.elapsed().as_secs(); + send_webhook( + format!("**Full backup completed in {}s**! *SKREEEEEEEEEE*", secs).as_str(), + &cfg, + ); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index f967fb7..a9dc8b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,178 +1,72 @@ -use chrono::{NaiveDateTime, Utc}; -use clap::{App, Arg}; -use std::fs::{create_dir_all, remove_dir_all, remove_file, DirEntry}; use std::path::PathBuf; -use std::time::Instant; +use structopt::StructOpt; mod backup; mod config; mod discord; mod region; -use crate::config::{AlbatrossConfig, WorldType}; -use backup::{backup_end, backup_nether, backup_overworld}; -use discord::send_webhook; +use crate::backup::{convert_backup_to_sp, do_backup}; +use crate::config::AlbatrossConfig; -/// Get the time of the backup from a file name -/// -/// # Param -/// * `archive_entry`: archive entry -fn get_time_from_file_name( - archive_entry: &DirEntry, -) -> Result, std::io::Error> { - let file_name = archive_entry.file_name().to_str().unwrap().to_string(); - let name: Vec<&str> = file_name.split('_').collect(); - - Ok(chrono::NaiveDateTime::parse_from_str(name[0], "%d-%m-%y-%H.%M.%S").ok()) +#[derive(Debug, StructOpt)] +#[structopt(about = "Backup your Minecraft Server!")] +struct Albatross { + /// Path to the Albatross config + #[structopt(short, long, env = "ALBATROSS_CONFIG", parse(from_os_str))] + config_path: PathBuf, + /// Subcommand + #[structopt(subcommand)] + sub_command: SubCommand, } -/// Removes the old backups from the ouput directory -/// -/// # Params -/// * `output_dir` - output directory containing -/// * `keep` - number of backups to keep -fn remove_old_backups(output_dir: &PathBuf, keep: u64) -> Result { - let mut backups = vec![]; - let mut num_of_removed_backups: usize = 0; +#[derive(Debug, StructOpt)] +enum SubCommand { + /// Backup a server + Backup { + /// Output location override + #[structopt(short = "o", long = "ouptut", parse(from_os_str))] + output: Option, + }, + /// Export a backup as a single player world + Export { + /// Convert backup to singleplayer world + #[structopt(parse(from_os_str))] + input_backup: PathBuf, - for entry in output_dir.read_dir()? { - let entry = entry?; - - if let Some(ext) = entry.path().extension() { - if ext == "gz" { - backups.push(entry); - } - } - } - - if backups.len() > keep as usize { - backups.sort_by(|a, b| { - let a_time = get_time_from_file_name(a).unwrap().unwrap(); - let b_time = get_time_from_file_name(b).unwrap().unwrap(); - - b_time.cmp(&a_time) - }); - - num_of_removed_backups = backups.len() - keep as usize; - - for _i in 0..num_of_removed_backups { - let oldest = backups.pop().unwrap(); - remove_file(oldest.path())?; - } - } - - Ok(num_of_removed_backups) + /// Output location override + #[structopt(parse(from_os_str))] + output: PathBuf, + }, } -/// Backup the configured worlds from a minecraft server -/// -/// # Params -/// * `cfg` - config file -fn do_backup(cfg: AlbatrossConfig) -> Result<(), std::io::Error> { - let server_base_dir = cfg.backup.minecraft_dir.clone(); - let worlds = cfg.world_config.clone().expect("No worlds configured"); - let time_str = Utc::now().format("%d-%m-%y-%H.%M.%S").to_string(); - let backup_name = format!("{}_backup.tar.gz", time_str); - let mut output_archive = cfg.backup.output_dir.clone(); - output_archive.push(backup_name); - let mut tmp_dir = cfg.backup.output_dir.clone(); - tmp_dir.push("tmp"); - remove_dir_all(&tmp_dir).ok(); - - create_dir_all(tmp_dir.clone()).unwrap(); - - send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg); - let timer = Instant::now(); - for world in worlds { - let mut world_dir = server_base_dir.clone(); - let world_name = world.world_name.clone(); - let world_type = match world.world_type.clone() { - Some(world_type) => world_type, - None => 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, - ); - let webhook_msg = match world_type { - WorldType::OVERWORLD => { - let (region_count, player_count) = - backup_overworld(world_dir.clone(), tmp_dir.clone(), &world)?; - format!( - "{} regions and {} player files backed up.", - region_count, player_count - ) - } - WorldType::NETHER => { - let region_count = backup_nether(world_dir, tmp_dir.clone(), &world)?; - format!("{} regions backed up.", region_count) - } - WorldType::END => { - let region_count = backup_end(world_dir, tmp_dir.clone(), &world)?; - format!("{} regions backed up.", region_count) - } - }; - - send_webhook(&webhook_msg, &cfg); - } else { - send_webhook(format!("Error: {} not found.", world_name).as_str(), &cfg); - println!("World \"{}\" not found", world_name.clone()); - } - } - - backup::compress_backup(&tmp_dir, &output_archive)?; - - remove_dir_all(&tmp_dir)?; - - let backups_removed = remove_old_backups(&cfg.backup.output_dir, cfg.backup.backups_to_keep)?; - - if backups_removed > 0 { - let msg = format!( - "Albatross mistook **{}** of your old backups for some french fries and ate them!! SKRAWWWW", - backups_removed - ); - send_webhook(msg.as_str(), &cfg); - } - - let secs = timer.elapsed().as_secs(); - send_webhook( - format!("**Full backup completed in {}s**! *SKREEEEEEEEEE*", secs).as_str(), - &cfg, - ); - Ok(()) -} - -/// Albatross -/// -/// Run backups of a Minecraft world fn main() { - let mut app = App::new("Albatross").about("Backup your worlds").arg( - Arg::with_name("config") - .index(1) - .short("c") - .long("config") - .value_name("CONFIG_PATH") - .help("Config file path"), - ); - // Get arg parser - let matches = app.clone().get_matches(); + let opt = Albatross::from_args(); - if let Some(cfg_path) = matches.value_of("config") { - let cfg = AlbatrossConfig::new(cfg_path).expect("Config not found"); + let cfg = AlbatrossConfig::new(opt.config_path.into_os_string().to_str().unwrap()) + .expect("Config not found"); - if cfg.world_config.is_some() { - println!("Starting backup"); - match do_backup(cfg) { - Ok(_) => println!("Backup complete"), - Err(e) => println!("Error doing backup: {:?}", e), + if cfg.world_config.is_some() { + match opt.sub_command { + SubCommand::Backup { output } => { + println!("Starting backup"); + match do_backup(cfg, output) { + Ok(_) => println!("Backup complete!"), + Err(e) => println!("Error doing backup: {:?}", e), + }; + } + SubCommand::Export { + input_backup, + output, + } => { + println!("Starting export"); + match convert_backup_to_sp(&cfg, &input_backup, &output) { + Ok(_) => println!("Export complete!"), + Err(e) => println!("Error exporting backup: {:?}", e), + }; } - } else { - println!("No worlds specified to backed up!") } } else { - app.print_help().expect("Unable to print help"); + println!("No worlds specified in config file!") } }