18 Commits
Author SHA1 Message Date
joeyahines 2283b6f851 Fixed paths pointing to wrong binary
Build and Release Albatross / build (release) Successful in 2m11s
Build and Test Albatross / build (push) Successful in 2m23s
2026-04-12 11:54:37 -06:00
joeyahines 4135098c3d Add gitea CI and task
Build and Test Formaty / build (push) Successful in 2m8s
Build and Release Formaty / build (release) Successful in 2m7s
2026-04-12 11:46:23 -06:00
joeyahines a22c781c52 Added 26.1 world format 2026-04-12 11:39:45 -06:00
joeyahines 38630a4d57 Fixed logging init and flipped DIMs 2025-07-26 13:30:08 -06:00
joeyahines a9cf1d5c6f Cleanup and modernize
+ Bump to rust 2024
+ Cleaned up old code
+ Added more fail safes in case certain files are missing
2025-07-26 13:14:52 -06:00
joeyahines 8378f2ead3 Added File remote backups
+ Copies the backup to a target directory
+ Useful for backing up to another local drive or mounted network drive
+ Various improvements
+ Fixed .drone.yml
2021-09-19 13:56:30 -06:00
joeyahines d207c6df19 Merge pull request 'Merge in missing changes' (#4) from backup_error_fix into master
Reviewed-on: https://git.canopymc.net/Canopy/Albatross/pulls/4
2021-09-16 01:40:31 +00:00
joeyahines b30dc8e0b3 Fixed remote backups for FTP 2021-02-12 20:17:07 -06:00
joeyahines 0ff76dec0a Removed FTPS backups and added FTP backups
+ Reverted to ftp 3.0.1
+ fmt + clippy
2021-02-12 20:02:57 -06:00
joeyahines 249ad8dad8 Merge pull request 'Added FTPS backups' (#9) from sftp into master
Reviewed-on: https://git.etztech.xyz/ZeroHD/Albatross/pulls/9
2021-02-11 00:32:45 +01:00
joeyahines 2366ec1faa Merge branch 'master' into sftp 2021-02-10 03:58:20 +01:00
joeyahines 9a264abe88 Added FTPS backups
+ Made remote backups more generic, should be easier to add new backup types in the future
+ General cleanup
+ clippy + fmt
2021-02-09 20:47:40 -06:00
joeyahines 678dcb583e Merge pull request 'Remote Backups' (#7) from sftp into master
Reviewed-on: https://git.etztech.xyz/ZeroHD/Albatross/pulls/7
2021-02-04 23:35:55 +01:00
joeyahines 9c7f9906ad Added remote section to example config in README.md 2021-02-04 16:21:31 -06:00
joeyahines 18c6411bf5 Merge branch 'master' into sftp 2021-02-04 01:49:31 +01:00
joeyahines 101a3f649a Added remote backup cleanup
+ Didn't find a great way to make the cleanup generic, a just copy/paste pretty much for now
+ fmt + clippy
2021-02-03 18:46:33 -06:00
joeyahines dc005e460b Added better error handling
+ fmt + clippy
2021-02-03 17:02:01 -06:00
joeyahines d94186c7b8 First pass on adding remote backups
+ Added remote_backup.rs to handle remote backup logic
+ Added `remote` section to the config for optional remote backup setup
+ Using ssh2 rust library for SFTP support
+ small tweaks
2021-02-02 21:21:55 -06:00
21 changed files with 3436 additions and 1115 deletions
-42
View File
@@ -1,42 +0,0 @@
---
kind: pipeline
name: compliance
type: docker
trigger:
event:
- pull_request
steps:
- name: build
pull: always
image: rust:1.46.0
commands:
- cargo build --verbose
---
kind: pipeline
name: release
type: docker
trigger:
branch:
- master
event:
- push
steps:
- name: build
pull: always
image: rust:1.46.0
commands:
- cargo build --verbose --release
- name: gitea-release
pull: always
image: jolheiser/drone-gitea-main:latest
settings:
token:
from_secret: gitea_token
base: https://git.etztech.xyz
files:
- "target/release/albatross"
+29
View File
@@ -0,0 +1,29 @@
name: Build and Test Albatross
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Install Task
uses: go-task/setup-task@v2
with:
repo-token: ${{ secrets.TASK_GITHUB_API_TOKEN }}
- uses: actions/checkout@v2
- name: Stable with rustfmt and clippy
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
components: rustfmt, clippy
- name: Lint Code
run: task check
- name: Build
run: task build
- name: Run Unit Tests
run: task test
- name: Upload Built Binary
uses: christopherHX/gitea-upload-artifact@v4
with:
name: albatross
path: target/debug/albatross
+23
View File
@@ -0,0 +1,23 @@
name: Build and Release Albatross
on: [release]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Install Task
uses: go-task/setup-task@v2
with:
repo-token: ${{ secrets.TASK_GITHUB_API_TOKEN }}
- uses: actions/checkout@v2
- name: Stable with rustfmt and clippy
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Build Release
run: task build:release
- uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: |-
target/release/albatross
Generated
+2319 -679
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -1,20 +1,24 @@
[package]
name = "albatross"
version = "0.3.0"
version = "0.7.1"
authors = ["Joey Hines <joey@ahines.net>"]
edition = "2018"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
structopt = "0.3.20"
serde = { version="1.0.116", features=["derive"] }
config = "0.9"
config = "0.15.13"
log = "0.4.8"
chrono = "0.4"
regex = "1.3.9"
flate2 = "1.0.14"
tar = "0.4.28"
reqwest = { version = "0.10", features = ["blocking", "json"] }
reqwest = { version = "0.13.2", features = ["blocking", "json"] }
discord-hooks-rs = { git = "https://github.com/joeyahines/discord-hooks-rs" }
anvil-region = "0.4.0"
anvil-region = "0.8.1"
ssh2 = "0.9.1"
ftp = "3.0.1"
env_logger = "0.11.8"
thiserror = "2.0.12"
+68 -12
View File
@@ -4,9 +4,11 @@ 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.
Backups can also be transferred to a remote server using SFTP.
## Help
```
albatross 0.3.0
albatross 0.7.0
Backup your Minecraft Server!
USAGE:
@@ -23,10 +25,7 @@ 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)
restore Restore certain chunks from a backup
Process finished with exit code 1
restore Restore certain chunks from a backup```
```
## Examples
@@ -40,28 +39,85 @@ Exporting a backup to a single player world:
Restoring a single chunk (from -2,-2 to 2,2):
`albatorss -c test.toml restore world backups/04-11-20_01.51.27_backup.tar.gz sp.tar.gz (0,0)`
`albatorss -c test.toml restore world backups/04-11-20_01.51.27_backup.tar.gz sp.tar.gz` (0,0)
Restoring a range of chunks (from -2,-2 to 2,2):
`albatorss -c test.toml restore world backups/04-11-20_01.51.27_backup.tar.gz sp.tar.gz (-2,-2) -u (2,2)`
`albatorss -c test.toml restore world backups/04-11-20_01.51.27_backup.tar.gz sp.tar.gz` (-2,-2) -u (2,2)
## Config
### For Minecraft Versions After 26.1
```toml
[backup]
# Minecraft sever directory
minecraft_dir = "/home/mc/server"
# Directory to place backups
output_dir = "/home/mc/backups"
# Optional Discord webhook
discord_webhook = "https://discordapp.com/api/webhooks/"
# Number of backups to keep
backups_to_keep = 10
# Discord Webhook
[backup.output_config]
# Directory to place backups
path = "./backups"
[world_26_plus_config]
world_name = "world"
[[world_26_plus_config.dimensions]]
# world name
world_name = "minecraft/overworld"
# world save radius (in blocks)
save_radius = 1000
[[world_26_plus_config.dimensions]]
# world name
world_name = "minecraft/the_end"
# world save radius (in blocks)
save_radius = 1000
[[world_26_plus_config.dimensions]]
# world name
world_name = "minecraft/the_nether"
# world save radius (in blocks)
save_radius = 1000
```
### For Minecraft Versions Before 26.1
```toml
# Local Backup Config
[backup]
# Minecraft sever directory
minecraft_dir = "/home/mc/server"
# Optional Discord webhook
discord_webhook = "https://discordapp.com/api/webhooks/"
# Number of backups to keep
backups_to_keep = 10
[backup.output_config]
# Directory to place backups
path = "/home/mc/backups"
# Optional remote_backup backup config
[remote]
# SFTP server host:port
sftp_server_addr = "localhost:22"
# Remote directory
remote_dir = "/home/backup/"
# Remote user
username = "user"
# Password Auth
password = "cooluser123"
# Key Auth
#public_key = /home/user/.ssh/id_rsa.pub"
#private_key = /home/user/.ssh/id_rsa"
# Backups to keep on the remote_backup host
backups_to_keep = 3
# World config options
[[world_config]]
# world name
# World name
world_name = "world"
# world save radius (in blocks)
# World save radius (in blocks)
save_radius = 8000
```
+38
View File
@@ -0,0 +1,38 @@
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
version:
desc: Print out Rust version info
cmds:
- cargo --version
- rustc --version
- cargo clippy --version
build:
desc: Debug build
cmds:
- cargo build --timings --tests --bins
test:
desc: Test code
deps: [build]
cmds:
- cargo test --locked
build:release:
desc: Release build
cmds:
- cargo build --release --locked --timings
fmt:
desc: Format Rust code
cmds:
- cargo fmt
check:
desc: Lint code with Clippy
cmds:
- cargo check
- cargo fmt --check
- cargo clippy
clean:
desc: Purge Rust build cache
cmds:
- cargo clean
+271 -180
View File
@@ -1,16 +1,21 @@
use crate::backup;
use crate::config::{AlbatrossConfig, WorldConfig, WorldType};
use crate::config::{
AlbatrossConfig, RemoteBackupConfig, World26PlusConfig, WorldConfig, WorldType,
};
use crate::discord::send_webhook;
use crate::error::{AlbatrossError, Result};
use crate::region::Region;
use chrono::{NaiveDateTime, Utc};
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 flate2::Compression;
use log::{error, info, warn};
use std::convert::TryFrom;
use std::fs::{
copy, create_dir, create_dir_all, remove_dir_all, remove_file, rename, DirEntry, File,
};
use std::path::PathBuf;
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;
@@ -20,15 +25,15 @@ use tar::Archive;
/// * `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,
mut world_path: PathBuf,
mut backup_path: PathBuf,
) -> Result<u64, std::io::Error> {
world_path.push(file_name);
backup_path.push(file_name);
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);
copy(world_path, backup_path)
if !world_path.exists() {
warn!("File '{world_path:?}' does not exist.");
}
Ok(copy(world_path, backup_path)?)
}
/// Backup a directory
@@ -37,26 +42,39 @@ pub fn backup_file(
/// * `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: &PathBuf,
backup_path: &PathBuf,
) -> Result<u64, std::io::Error> {
let mut src_dir = world_path.clone();
src_dir.push(dir_name);
let mut backup_dir = backup_path.clone();
backup_dir.push(dir_name);
create_dir(&backup_dir)?;
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.clone();
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)
}
@@ -71,14 +89,18 @@ pub fn backup_dir(
pub fn backup_region(
dir_name: &str,
save_radius: u64,
world_path: &PathBuf,
backup_path: &PathBuf,
) -> Result<u64, std::io::Error> {
world_path: &Path,
backup_path: &Path,
) -> Result<u64> {
let mut count: u64 = 0;
let mut src_dir = world_path.clone();
src_dir.push(dir_name);
let mut backup_dir = backup_path.clone();
backup_dir.push(dir_name);
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;
@@ -87,8 +109,10 @@ pub fn backup_region(
let entry = entry?;
let file_name = entry.file_name().to_str().unwrap().to_string();
if let Ok(region) = Region::try_from(file_name) {
if region.x.abs() <= save_radius && region.y.abs() <= save_radius {
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());
@@ -96,7 +120,6 @@ pub fn backup_region(
count += 1;
}
}
}
Ok(count)
}
@@ -108,21 +131,15 @@ pub fn backup_region(
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_world(
world_path: PathBuf,
mut backup_path: PathBuf,
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64, std::io::Error> {
let region_count;
backup_path.push(&world_config.world_name);
) -> 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)?;
region_count = backup_region(
"region",
world_config.save_radius,
&world_path,
&backup_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)
}
@@ -133,19 +150,19 @@ pub fn backup_world(
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_overworld(
world_path: PathBuf,
backup_path: PathBuf,
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<(u64, u64), std::io::Error> {
backup_dir("data", &world_path, &backup_path)?;
backup_dir("stats", &world_path, &backup_path)?;
) -> Result<(u64, u64)> {
backup_dir("data", world_path, backup_path)?;
backup_dir("stats", world_path, backup_path).ok();
backup_file("level.dat", world_path.clone(), backup_path.clone())?;
backup_file("level.dat_old", world_path.clone(), backup_path.clone())?;
backup_file("session.lock", world_path.clone(), backup_path.clone())?;
backup_file("uid.dat", world_path.clone(), backup_path.clone())?;
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 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))
@@ -158,14 +175,13 @@ pub fn backup_overworld(
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_nether(
world_path: PathBuf,
backup_path: PathBuf,
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64, std::io::Error> {
let mut nether_path = world_path;
nether_path.push("DIM-1");
) -> Result<u64> {
let nether_path = world_path.join(WorldType::Nether.dim_name());
backup_world(nether_path, backup_path, world_config)
backup_world(&nether_path, backup_path, world_config)
}
/// Backup the end
@@ -175,14 +191,13 @@ pub fn backup_nether(
/// * `backup_path` - path to the backup folder
/// * `world_config` - world config options
pub fn backup_end(
world_path: PathBuf,
backup_path: PathBuf,
world_path: &Path,
backup_path: &Path,
world_config: &WorldConfig,
) -> Result<u64, std::io::Error> {
let mut end_path = world_path;
end_path.push("DIM1");
) -> Result<u64> {
let end_path = world_path.join(WorldType::End.dim_name());
backup_world(end_path, backup_path, world_config)
backup_world(&end_path, backup_path, world_config)
}
/// Compress the backup after the files have been copied
@@ -190,7 +205,7 @@ pub fn backup_end(
/// # Param
/// * `tmp_dir`: tmp directory with the backed up files
/// * `output_file`: output archive
pub fn compress_backup(tmp_dir: &PathBuf, output_file: &PathBuf) -> Result<(), std::io::Error> {
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);
@@ -198,7 +213,7 @@ pub fn compress_backup(tmp_dir: &PathBuf, output_file: &PathBuf) -> Result<(), s
Ok(())
}
pub fn uncompress_backup(backup: &PathBuf) -> Result<PathBuf, std::io::Error> {
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);
@@ -214,30 +229,23 @@ pub fn uncompress_backup(backup: &PathBuf) -> Result<PathBuf, std::io::Error> {
/// * 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> {
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 = match world.world_type.clone() {
Some(world_type) => world_type,
None => WorldType::OVERWORLD,
};
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 => {
WorldType::Overworld => {
rename(src.clone().join("poi"), dest.clone().join("poi"))?;
rename(src.clone().join("region"), dest.clone().join("region"))?;
}
WorldType::NETHER => {
WorldType::Nether => {
rename(src, dest.clone().join("DIM-1"))?;
}
WorldType::END => {
WorldType::End => {
rename(src, dest.clone().join("DIM1"))?;
}
}
@@ -250,137 +258,220 @@ pub fn convert_backup_to_sp(
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<Option<NaiveDateTime>, std::io::Error> {
let file_name = archive_entry.file_name().to_str().unwrap().to_string();
let name: Vec<&str> = file_name.split("_backup.tar.gz").collect();
Ok(chrono::NaiveDateTime::parse_from_str(name[0], "%d-%m-%y_%H.%M.%S").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()?;
}
/// 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<usize, std::io::Error> {
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)
Ok(())
}
/// Backup the configured worlds from a minecraft server
///
/// # Params
/// * `cfg` - config file
pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<(), std::io::Error> {
pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> {
let server_base_dir = cfg.backup.minecraft_dir.clone();
let worlds = cfg.world_config.clone().expect("No worlds configured");
let timer = Instant::now();
let time_str = Utc::now().format("%d-%m-%y_%H.%M.%S").to_string();
let backup_name = format!("{}_backup.tar.gz", time_str);
let backup_name = format!("{time_str}_backup.tar.gz");
let mut output_archive = match output {
Some(out_path) => out_path,
None => cfg.backup.output_dir.clone(),
None => cfg.backup.output_config.path.clone(),
};
output_archive.push(backup_name);
let mut tmp_dir = cfg.backup.output_dir.clone();
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()).unwrap();
create_dir_all(tmp_dir.clone())?;
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);
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 {
send_webhook(format!("Error: {} not found.", world_name).as_str(), &cfg);
println!("World \"{}\" not found", world_name.clone());
}
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);
}
backup::compress_backup(&tmp_dir, &output_archive)?;
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 backups_removed = remove_old_backups(&cfg.backup.output_dir, cfg.backup.backups_to_keep)?;
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 **{}** of your old backups for some french fries and ate them!! SKRAWWWW",
backups_removed
"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 {}s**! *SKREEEEEEEEEE*", secs).as_str(),
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(())
}
+32 -30
View File
@@ -1,41 +1,20 @@
use regex::Regex;
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
use std::str::FromStr;
use thiserror::Error;
/// Chunk error
#[derive(Debug)]
#[derive(Debug, Error)]
pub enum ChunkCoordinateErr {
/// Error parsing integer
ParseIntError(ParseIntError),
/// Regex error
RegexError(regex::Error),
/// Invalid chunk coordinate given
#[error("Failed to parse int: {0}")]
ParseIntError(#[from] ParseIntError),
#[error("Regex error: {0}")]
RegexError(#[from] regex::Error),
#[error("Invalid chunk coordinate")]
InvalidChunk,
}
impl From<ParseIntError> for ChunkCoordinateErr {
fn from(e: ParseIntError) -> Self {
Self::ParseIntError(e)
}
}
impl From<regex::Error> for ChunkCoordinateErr {
fn from(e: regex::Error) -> Self {
Self::RegexError(e)
}
}
impl fmt::Display for ChunkCoordinateErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Unable to parse chunk range: {:?}", self)
}
}
impl Error for ChunkCoordinateErr {}
/// Chunk Coordinate paiir
/// Chunk Coordinate pair
#[derive(Debug)]
pub struct ChunkCoordinate {
/// X Coordinate
@@ -48,7 +27,7 @@ impl FromStr for ChunkCoordinate {
type Err = ChunkCoordinateErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = Regex::new(r"\((?P<x>-?[0-9]*),(?P<z>-?[0-9]*)\)").unwrap();
let re = Regex::new(r"\((?P<x>-?[0-9]*),(?P<z>-?[0-9]*)\)")?;
if let Some(cap) = re.captures(s) {
let x = cap["x"].parse::<i32>()?;
@@ -60,3 +39,26 @@ impl FromStr for ChunkCoordinate {
}
}
}
#[cfg(test)]
mod test {
use crate::chunk_coordinate::ChunkCoordinate;
use std::str::FromStr;
#[test]
fn parse_chunk_coordinate_success() {
let chunk_x = 5;
let chunk_z = -15;
let chunk_string = format!("({chunk_x},{chunk_z})");
let chunk = ChunkCoordinate::from_str(chunk_string.as_str()).unwrap();
assert_eq!(chunk.x, chunk_x);
assert_eq!(chunk.z, chunk_z);
}
#[test]
fn parse_chunk_coordinate_failure() {
assert!(ChunkCoordinate::from_str("(nan,5)").is_err());
}
}
-59
View File
@@ -1,59 +0,0 @@
use config::{Config, ConfigError, File};
use serde::Deserialize;
use std::path::PathBuf;
/// World types supported
#[derive(Debug, Deserialize, Clone)]
pub enum WorldType {
/// The End (DIM1)
END,
/// Nether (DIM-1)
NETHER,
/// Overworld
OVERWORLD,
}
impl From<String> for WorldType {
/// Convert config strings to WorldType
fn from(string: String) -> Self {
match string.as_str() {
"END" => WorldType::END,
"NETHER" => WorldType::NETHER,
_ => WorldType::OVERWORLD,
}
}
}
/// Config for individual WorldConfig
#[derive(Debug, Deserialize, Clone)]
pub struct WorldConfig {
pub world_name: String,
pub save_radius: u64,
pub world_type: Option<WorldType>,
}
/// Config for doing backups
#[derive(Debug, Deserialize, Clone)]
pub struct BackupConfig {
pub minecraft_dir: PathBuf,
pub output_dir: PathBuf,
pub backups_to_keep: u64,
pub discord_webhook: Option<String>,
}
/// Configs
#[derive(Debug, Deserialize, Clone)]
pub struct AlbatrossConfig {
pub backup: BackupConfig,
pub world_config: Option<Vec<WorldConfig>>,
}
impl AlbatrossConfig {
/// Create new backup from file
pub fn new(config_path: &str) -> Result<Self, ConfigError> {
let mut cfg = Config::new();
cfg.merge(File::with_name(config_path))?;
cfg.try_into()
}
}
+92
View File
@@ -0,0 +1,92 @@
pub(crate) mod remote;
use crate::config::remote::{FTPConfig, FileConfig, SFTPConfig};
use config::{Config, ConfigError, File};
use serde::Deserialize;
use std::path::PathBuf;
/// World types supported
#[derive(Debug, Deserialize, Clone)]
pub enum WorldType {
End,
Nether,
Overworld,
}
impl From<String> for WorldType {
/// Convert config strings to WorldType
fn from(string: String) -> Self {
match string.as_str() {
"END" => WorldType::End,
"NETHER" => WorldType::Nether,
_ => WorldType::Overworld,
}
}
}
impl WorldType {
pub fn dim_number(&self) -> i8 {
match self {
WorldType::End => 1,
WorldType::Nether => -1,
WorldType::Overworld => 0,
}
}
pub fn dim_name(&self) -> String {
format!("DIM{}", self.dim_number())
}
}
/// Config for individual world configuration
#[derive(Debug, Deserialize, Clone)]
pub struct WorldConfig {
pub world_name: String,
pub save_radius: u64,
pub world_type: Option<WorldType>,
}
/// Config for doing backups
#[derive(Debug, Deserialize, Clone)]
pub struct BackupConfig {
pub minecraft_dir: PathBuf,
pub backups_to_keep: usize,
pub discord_webhook: Option<String>,
pub output_config: FileConfig,
}
/// Config for remote_backup backups
#[derive(Debug, Deserialize, Clone)]
pub struct RemoteBackupConfig {
pub backups_to_keep: usize,
pub sftp: Option<SFTPConfig>,
pub ftp: Option<FTPConfig>,
pub file: Option<FileConfig>,
}
/// Config for individual world configuration
#[derive(Debug, Deserialize, Clone)]
pub struct World26PlusConfig {
pub world_name: String,
pub dimensions: Vec<WorldConfig>,
}
/// Configs
#[derive(Debug, Deserialize, Clone)]
pub struct AlbatrossConfig {
pub backup: BackupConfig,
pub world_config: Option<Vec<WorldConfig>>,
pub world_26_plus_config: Option<World26PlusConfig>,
pub remote: Option<RemoteBackupConfig>,
}
impl AlbatrossConfig {
/// Create new backup from file
pub fn new(config_path: &str) -> Result<Self, ConfigError> {
let cfg = Config::builder()
.add_source(File::with_name(config_path))
.build()?;
cfg.try_deserialize()
}
}
+39
View File
@@ -0,0 +1,39 @@
use serde::Deserialize;
use std::path::PathBuf;
/// SFTP Config
#[derive(Debug, Deserialize, Clone)]
pub struct SFTPConfig {
/// Remote server address
pub server_addr: String,
/// Remote output directory
pub remote_dir: PathBuf,
/// Remote server username
pub username: String,
/// Public key for key auth
pub public_key: Option<PathBuf>,
/// Private key for key auth
pub private_key: Option<PathBuf>,
/// Password if using password auth
pub password: Option<String>,
}
/// FTP Config
#[derive(Debug, Deserialize, Clone)]
pub struct FTPConfig {
/// Remote server address
pub server_addr: String,
/// Remote output directory
pub remote_dir: PathBuf,
/// Remote server username
pub username: String,
/// Password
pub password: String,
}
/// File Config
#[derive(Debug, Deserialize, Clone)]
pub struct FileConfig {
/// Path to backup to
pub path: PathBuf,
}
+9 -1
View File
@@ -1,5 +1,6 @@
use crate::config::AlbatrossConfig;
use discord_hooks_rs::DiscordWebhook;
use log::{debug, error};
/// Sends a webhook to Discord if its configured
///
@@ -11,6 +12,13 @@ pub fn send_webhook(msg: &str, cfg: &AlbatrossConfig) {
let json = DiscordWebhook::new().content(msg);
let client = reqwest::blocking::Client::new();
client.post(webhook).json(&json).send().ok();
match client.post(webhook).json(&json).send() {
Ok(_) => {
debug!("Sent webhook with message '{msg}'")
}
Err(err) => {
error!("Failed to send webhook: '{err:?}'")
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
use crate::region::RegionParseError;
pub type Result<T> = std::result::Result<T, AlbatrossError>;
#[derive(Debug)]
pub enum AlbatrossError {
FileError(std::io::Error),
SSHError(ssh2::Error),
ChunkParseError(crate::chunk_coordinate::ChunkCoordinateErr),
RegionParseError(RegionParseError),
ChronoParseError(chrono::ParseError),
NoSSHAuth,
FTPError(ftp::FtpError),
MissingConfig,
}
impl std::error::Error for AlbatrossError {}
impl std::fmt::Display for AlbatrossError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AlbatrossError::FileError(e) => write!(f, "File I/O error: {e}"),
AlbatrossError::SSHError(e) => write!(f, "SSH error: {e}"),
AlbatrossError::ChunkParseError(e) => {
write!(f, "Unable to parse chunk coordinate: {e}")
}
AlbatrossError::RegionParseError(e) => write!(f, "Unable to parse region name: {e}"),
AlbatrossError::ChronoParseError(e) => write!(f, "Unable to parse time: {e}"),
AlbatrossError::NoSSHAuth => write!(f, "No SSH auth methods provided in the config"),
AlbatrossError::FTPError(e) => write!(f, "FTP error: {e}"),
AlbatrossError::MissingConfig => write!(f, "Missing world config"),
}
}
}
impl From<std::io::Error> for AlbatrossError {
fn from(e: std::io::Error) -> Self {
AlbatrossError::FileError(e)
}
}
impl From<ssh2::Error> for AlbatrossError {
fn from(e: ssh2::Error) -> Self {
AlbatrossError::SSHError(e)
}
}
impl From<crate::chunk_coordinate::ChunkCoordinateErr> for AlbatrossError {
fn from(e: crate::chunk_coordinate::ChunkCoordinateErr) -> Self {
AlbatrossError::ChunkParseError(e)
}
}
impl From<crate::region::RegionParseError> for AlbatrossError {
fn from(e: RegionParseError) -> Self {
AlbatrossError::RegionParseError(e)
}
}
impl From<chrono::ParseError> for AlbatrossError {
fn from(e: chrono::ParseError) -> Self {
AlbatrossError::ChronoParseError(e)
}
}
impl From<ftp::FtpError> for AlbatrossError {
fn from(e: ftp::FtpError) -> Self {
AlbatrossError::FTPError(e)
}
}
+20 -16
View File
@@ -1,3 +1,4 @@
use log::{LevelFilter, info};
use std::path::PathBuf;
use structopt::StructOpt;
@@ -5,7 +6,9 @@ mod backup;
mod chunk_coordinate;
mod config;
mod discord;
mod error;
mod region;
mod remote;
mod restore;
use crate::backup::{convert_backup_to_sp, do_backup};
@@ -61,28 +64,32 @@ enum SubCommand {
}
fn main() {
env_logger::builder()
.filter_level(LevelFilter::Info)
.parse_default_env()
.init();
let opt = Albatross::from_args();
let cfg = AlbatrossConfig::new(opt.config_path.into_os_string().to_str().unwrap())
.expect("Config not found");
.expect("Config error");
if cfg.world_config.is_some() {
match opt.sub_command {
SubCommand::Backup { output } => {
println!("Starting backup");
info!("Starting backup");
match do_backup(cfg, output) {
Ok(_) => println!("Backup complete!"),
Err(e) => println!("Error doing backup: {:?}", e),
Ok(_) => info!("Backup complete!"),
Err(e) => info!("Error doing backup: {e:?}"),
};
}
SubCommand::Export {
input_backup,
output,
} => {
println!("Starting export");
info!("Starting export");
match convert_backup_to_sp(&cfg, &input_backup, &output) {
Ok(_) => println!("Export complete!"),
Err(e) => println!("Error exporting backup: {:?}", e),
Ok(_) => info!("Export complete!"),
Err(e) => info!("Error exporting backup: {e:?}"),
};
}
SubCommand::Restore {
@@ -92,7 +99,7 @@ fn main() {
chunk,
upper_bound,
} => {
println!("Starting restore");
info!("Starting restore");
let server_directory = match server_directory {
Some(dir) => dir,
@@ -107,8 +114,8 @@ fn main() {
&backup_path,
&server_directory,
) {
Ok(count) => println!("Restored {} chunks!", count),
Err(e) => println!("Error restoring backup: {:?}", e),
Ok(count) => info!("Restored {count} chunks!"),
Err(e) => info!("Error restoring backup: {e:?}"),
};
} else {
match restore_chunk_from_backup(
@@ -117,13 +124,10 @@ fn main() {
&backup_path,
&server_directory,
) {
Ok(_) => println!("Restored chunk!"),
Err(e) => println!("Error restoring backup: {:?}", e),
Ok(_) => info!("Restored chunk!"),
Err(e) => info!("Error restoring backup: {e:?}"),
};
}
}
}
} else {
println!("No worlds specified in config file!")
}
}
+39 -15
View File
@@ -1,18 +1,17 @@
use regex::Regex;
use std::convert::TryFrom;
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct RegionParseError;
impl fmt::Display for RegionParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Unable to parse region file name")
#[derive(Debug, Clone, Error)]
pub enum RegionParseError {
#[error("Regex Error '{0}'")]
RegexError(#[from] regex::Error),
#[error("Int parse error '{0}'")]
IntParseError(#[from] ParseIntError),
#[error("Cannot parse region file name '{0}'")]
RegionNameParseFailure(String),
}
}
impl Error for RegionParseError {}
/// Struct to store information about the region
pub struct Region {
@@ -27,16 +26,41 @@ impl TryFrom<String> for Region {
/// Try from string
fn try_from(value: String) -> Result<Self, Self::Error> {
let re = Regex::new(r"r\.(?P<x>-?[0-9]*)+\.(?P<y>-?[0-9]*)").unwrap();
let re = Regex::new(r"r\.(?P<x>-?[0-9]*)+\.(?P<y>-?[0-9]*)")?;
if re.is_match(&value) {
let captures = re.captures(value.as_str()).unwrap();
return Ok(Region {
x: captures["x"].parse::<i64>().unwrap(),
y: captures["y"].parse::<i64>().unwrap(),
x: captures["x"].parse::<i64>()?,
y: captures["y"].parse::<i64>()?,
});
}
Err(RegionParseError)
Err(RegionParseError::RegionNameParseFailure(value))
}
}
#[cfg(test)]
mod test {
use crate::region::Region;
#[test]
fn test_parse_success() {
let region_x = 5;
let region_y = -15;
let region_string = format!("r.{region_x}.{region_y}");
let region = Region::try_from(region_string).unwrap();
assert_eq!(region.x, region_x);
assert_eq!(region.y, region_y);
}
#[test]
fn test_parse_failure() {
let region_y = -15;
let region_string = format!("r.pb.{region_y}");
assert!(Region::try_from(region_string).is_err());
}
}
+47
View File
@@ -0,0 +1,47 @@
use crate::config::remote::FileConfig;
use crate::error::Result;
use crate::remote::{PathLocation, RemoteBackupSite};
use std::path::PathBuf;
pub struct FileBackup {
/// Target directory on the file system
target_dir: PathBuf,
/// Number of backups to keep
backups_to_keep: usize,
}
impl FileBackup {
/// New FileBackup
pub fn new(config: &FileConfig, backups_to_keep: usize) -> Result<Self> {
Ok(Self {
target_dir: config.path.clone(),
backups_to_keep,
})
}
}
impl RemoteBackupSite for FileBackup {
type FileType = PathLocation;
fn backup_to_remote(&mut self, file: PathBuf) -> Result<()> {
let dest = self.target_dir.join(file.file_name().unwrap());
std::fs::copy(file, dest)?;
Ok(())
}
fn get_backups(&mut self) -> Result<Vec<Self::FileType>> {
Ok(self
.target_dir
.read_dir()?
.filter_map(|file| Self::FileType::new(file.unwrap().path()))
.collect())
}
fn remove_backup(&mut self, backup: Self::FileType) -> Result<()> {
Ok(std::fs::remove_file(backup.location)?)
}
fn backups_to_keep(&self) -> usize {
self.backups_to_keep
}
}
+67
View File
@@ -0,0 +1,67 @@
use ftp::FtpStream;
use std::path::PathBuf;
use crate::config::remote::FTPConfig;
use crate::error;
use crate::remote::{PathLocation, RemoteBackupSite};
/// FTP Remote Site
pub struct FTPBackup {
/// FTP command stream
stream: FtpStream,
/// Remote target directory
target_dir: PathBuf,
/// Number of backups to keep
backups_to_keep: usize,
}
impl FTPBackup {
/// New FTPBackup
pub fn new(config: &FTPConfig, backups_to_keep: usize) -> error::Result<Self> {
let mut ftp_stream = FtpStream::connect(&config.server_addr)?;
ftp_stream.login(&config.username, &config.password)?;
Ok(Self {
stream: ftp_stream,
target_dir: config.remote_dir.clone(),
backups_to_keep,
})
}
}
impl Drop for FTPBackup {
fn drop(&mut self) {
self.stream.quit().ok();
}
}
impl RemoteBackupSite for FTPBackup {
type FileType = PathLocation;
fn backup_to_remote(&mut self, file: PathBuf) -> error::Result<()> {
let mut local_file = std::fs::File::open(&file)?;
let location = self.target_dir.join(file.file_name().unwrap());
self.stream
.put(location.to_str().unwrap(), &mut local_file)?;
Ok(())
}
fn get_backups(&mut self) -> error::Result<Vec<Self::FileType>> {
let files = self.stream.nlst(Some(self.target_dir.to_str().unwrap()))?;
Ok(files
.into_iter()
.filter_map(|file| Self::FileType::new(PathBuf::from(file)))
.collect())
}
fn remove_backup(&mut self, backup: Self::FileType) -> error::Result<()> {
Ok(self.stream.rm(backup.location.to_str().unwrap())?)
}
fn backups_to_keep(&self) -> usize {
self.backups_to_keep
}
}
+104
View File
@@ -0,0 +1,104 @@
use std::path::PathBuf;
use chrono::NaiveDateTime;
use crate::error::Result;
pub mod file;
pub mod ftp;
pub mod sftp;
#[allow(dead_code)]
pub trait RemoteBackupFile {
/// Type containing the location of the remote_backup backup
type LocationType;
/// Get the underlying location type
fn location(&self) -> Self::LocationType;
/// Get the time the remote_backup file was created
fn time_created(&self) -> NaiveDateTime;
/// Parse the time created from the file name
fn parse_file_name(file_name: &str) -> Option<NaiveDateTime> {
let time: Vec<&str> = file_name.split("_backup.tar.gz").collect();
if let Some(time_str) = time.first() {
NaiveDateTime::parse_from_str(time_str, "%d-%m-%y_%H.%M.%S").ok()
} else {
None
}
}
}
pub trait RemoteBackupSite {
/// Struct representing the location of a backup on the site
type FileType: RemoteBackupFile;
/// Backup a file to the remote_backup site
fn backup_to_remote(&mut self, file: PathBuf) -> Result<()>;
/// Get the locations backups contained on the remote_backup site
fn get_backups(&mut self) -> Result<Vec<Self::FileType>>;
/// Remove a backup from the side
fn remove_backup(&mut self, backup: Self::FileType) -> Result<()>;
/// Number of backups to keep on the site
fn backups_to_keep(&self) -> usize;
/// Cleanup old backups on the remote_backup site
fn cleanup(&mut self) -> Result<usize> {
let mut backups = self.get_backups()?;
backups.sort_by_key(|backup| backup.time_created());
let mut backups: Vec<Self::FileType> = backups.into_iter().rev().collect();
let mut removed_count: usize = 0;
if backups.len() > self.backups_to_keep() {
for _ in 0..(backups.len() - self.backups_to_keep()) {
if let Some(backup) = backups.pop() {
self.remove_backup(backup)?;
removed_count += 1;
}
}
}
Ok(removed_count)
}
}
/// Backup location that can be represented by a path
pub struct PathLocation {
location: PathBuf,
time_created: NaiveDateTime,
}
impl PathLocation {
/// New PathLocation
fn new(path: PathBuf) -> Option<Self> {
if let Some(file_name) = path.file_name() {
let file_name = file_name.to_str().unwrap();
Self::parse_file_name(file_name).map(|time| Self {
location: path,
time_created: time,
})
} else {
None
}
}
}
impl RemoteBackupFile for PathLocation {
type LocationType = PathBuf;
fn location(&self) -> Self::LocationType {
self.location.to_path_buf()
}
fn time_created(&self) -> NaiveDateTime {
self.time_created
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::net::TcpStream;
use std::path::PathBuf;
use ssh2::Session;
use crate::config::remote::SFTPConfig;
use crate::error;
use crate::error::AlbatrossError;
use crate::remote::{PathLocation, RemoteBackupSite};
/// SFTP Remote Site
pub struct SFTPBackup {
/// SSH Session
session: Session,
/// Remote target directory
target_dir: PathBuf,
/// Number of backups to keep
backups_to_keep: usize,
}
impl SFTPBackup {
/// New SFTPBackup
pub fn new(config: &SFTPConfig, backups_to_keep: usize) -> error::Result<Self> {
let tcp = TcpStream::connect(&config.server_addr)?;
let mut sess = Session::new()?;
sess.set_tcp_stream(tcp);
sess.handshake().unwrap();
if let Some(password) = &config.password {
sess.userauth_password(&config.username, password)?;
} else if let Some(key) = &config.private_key {
let public_key = config.public_key.as_deref();
sess.userauth_pubkey_file(&config.username, public_key, key, None)?;
} else {
return Err(AlbatrossError::NoSSHAuth);
}
Ok(Self {
session: sess,
target_dir: config.remote_dir.clone(),
backups_to_keep,
})
}
}
impl RemoteBackupSite for SFTPBackup {
type FileType = PathLocation;
fn backup_to_remote(&mut self, file: PathBuf) -> error::Result<()> {
let remote_path = self.target_dir.join(file.file_name().unwrap());
let mut local_file = std::fs::File::open(&file)?;
let sftp = self.session.sftp()?;
let mut remote_file = sftp.create(&remote_path)?;
std::io::copy(&mut local_file, &mut remote_file)?;
Ok(())
}
fn get_backups(&mut self) -> error::Result<Vec<Self::FileType>> {
let files = self.session.sftp()?.readdir(&self.target_dir)?;
Ok(files
.into_iter()
.filter_map(|(file, _)| Self::FileType::new(file))
.collect())
}
fn remove_backup(&mut self, backup: Self::FileType) -> error::Result<()> {
Ok(self.session.sftp()?.unlink(&backup.location)?)
}
fn backups_to_keep(&self) -> usize {
self.backups_to_keep
}
}
+29 -23
View File
@@ -1,9 +1,10 @@
use crate::backup::uncompress_backup;
use crate::chunk_coordinate::ChunkCoordinate;
use anvil_region::AnvilChunkProvider;
use std::error;
use crate::error::Result;
use anvil_region::position::{RegionChunkPosition, RegionPosition};
use anvil_region::provider::{FolderRegionProvider, RegionProvider};
use std::fs::remove_dir_all;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Struct for manipulating a world from a backup
struct RestoreAccess {
@@ -15,11 +16,7 @@ struct RestoreAccess {
impl RestoreAccess {
/// Create new RestoreAccess
pub fn new(
world_name: &str,
src_path: &PathBuf,
dest_path: &PathBuf,
) -> Result<Self, std::io::Error> {
pub fn new(world_name: &str, src_path: &Path, dest_path: &Path) -> Result<Self> {
let src_path = uncompress_backup(src_path)?.join(world_name).join("region");
let dest_path = dest_path.join(world_name).join("region");
@@ -29,20 +26,29 @@ impl RestoreAccess {
})
}
/// Copy chunk from source to desination
/// Copy chunk from source to destination
pub fn copy_chunk(&self, x: i32, z: i32) {
let src_provider = AnvilChunkProvider::new(self.src_path.to_str().unwrap());
let dest_provider = AnvilChunkProvider::new(self.dest_path.to_str().unwrap());
let region_position = RegionPosition::from_chunk_position(x, z);
let region_chunk_position = RegionChunkPosition::from_chunk_position(x, z);
let chunk = src_provider.load_chunk(x, z).expect("Unable to load chunk");
dest_provider
.save_chunk(x, z, chunk)
.expect("Unable to save chunk");
let src_provider = FolderRegionProvider::new(self.src_path.to_str().unwrap());
let dest_provider = FolderRegionProvider::new(self.dest_path.to_str().unwrap());
let mut src_region = src_provider.get_region(region_position).unwrap();
let src_chunk_compound_tag = src_region
.read_chunk(region_chunk_position)
.expect("Unable to load chunk");
let mut dst_region = dest_provider.get_region(region_position).unwrap();
dst_region
.write_chunk(region_chunk_position, src_chunk_compound_tag)
.expect("Unable to write chunk");
}
/// Cleanup process
pub fn cleanup(self) -> Result<(), std::io::Error> {
remove_dir_all("tmp")
pub fn cleanup(self) -> Result<()> {
Ok(remove_dir_all("tmp")?)
}
}
@@ -51,9 +57,9 @@ pub fn restore_range_from_backup(
world_name: &str,
lower: ChunkCoordinate,
upper: ChunkCoordinate,
backup_path: &PathBuf,
minecraft_dir: &PathBuf,
) -> Result<u64, Box<dyn error::Error>> {
backup_path: &Path,
minecraft_dir: &Path,
) -> Result<u64> {
let chunk_access = RestoreAccess::new(world_name, backup_path, minecraft_dir)?;
let mut count = 0;
@@ -72,9 +78,9 @@ pub fn restore_range_from_backup(
pub fn restore_chunk_from_backup(
world_name: &str,
chunk: ChunkCoordinate,
backup_path: &PathBuf,
minecraft_dir: &PathBuf,
) -> Result<(), Box<dyn error::Error>> {
backup_path: &Path,
minecraft_dir: &Path,
) -> Result<()> {
let chunk_access = RestoreAccess::new(world_name, backup_path, minecraft_dir)?;
chunk_access.copy_chunk(chunk.x, chunk.z);