Compare commits

..

3 Commits

Author SHA1 Message Date
2283b6f851
Fixed paths pointing to wrong binary 2026-04-12 11:54:37 -06:00
4135098c3d
Add gitea CI and task 2026-04-12 11:46:23 -06:00
a22c781c52
Added 26.1 world format 2026-04-12 11:39:45 -06:00
11 changed files with 822 additions and 288 deletions

View File

@ -1,42 +0,0 @@
---
kind: pipeline
name: compliance
type: docker
trigger:
event:
- pull_request
steps:
- name: build
pull: always
image: rust:1.55.0
commands:
- cargo build --verbose
---
kind: pipeline
name: release
type: docker
trigger:
branch:
- master
event:
- push
steps:
- name: build
pull: always
image: rust:1.55.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.canopymc.net
files:
- "target/release/albatross"

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

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

661
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "albatross" name = "albatross"
version = "0.6.1" version = "0.7.1"
authors = ["Joey Hines <joey@ahines.net>"] authors = ["Joey Hines <joey@ahines.net>"]
edition = "2024" edition = "2024"
@ -15,7 +15,7 @@ chrono = "0.4"
regex = "1.3.9" regex = "1.3.9"
flate2 = "1.0.14" flate2 = "1.0.14"
tar = "0.4.28" tar = "0.4.28"
reqwest = { version = "0.12.22", features = ["blocking", "json"] } reqwest = { version = "0.13.2", features = ["blocking", "json"] }
discord-hooks-rs = { git = "https://github.com/joeyahines/discord-hooks-rs" } discord-hooks-rs = { git = "https://github.com/joeyahines/discord-hooks-rs" }
anvil-region = "0.8.1" anvil-region = "0.8.1"
ssh2 = "0.9.1" ssh2 = "0.9.1"

View File

@ -8,7 +8,7 @@ Backups can also be transferred to a remote server using SFTP.
## Help ## Help
``` ```
albatross 0.4.0 albatross 0.7.0
Backup your Minecraft Server! Backup your Minecraft Server!
USAGE: USAGE:
@ -25,10 +25,7 @@ SUBCOMMANDS:
backup Backup a server backup Backup a server
export Export a backup as a single player world export Export a backup as a single player world
help Prints this message or the help of the given subcommand(s) help Prints this message or the help of the given subcommand(s)
restore Restore certain chunks from a backup restore Restore certain chunks from a backup```
Process finished with exit code 1
``` ```
## Examples ## Examples
@ -49,6 +46,44 @@ 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 ## Config
### For Minecraft Versions After 26.1
```toml
[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 = "./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 ```toml
# Local Backup Config # Local Backup Config
[backup] [backup]

38
Taskfile.yaml Normal file
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

View File

@ -1,6 +1,8 @@
use crate::config::{AlbatrossConfig, RemoteBackupConfig, WorldConfig, WorldType}; use crate::config::{
AlbatrossConfig, RemoteBackupConfig, World26PlusConfig, WorldConfig, WorldType,
};
use crate::discord::send_webhook; use crate::discord::send_webhook;
use crate::error::Result; use crate::error::{AlbatrossError, Result};
use crate::region::Region; use crate::region::Region;
use crate::remote::RemoteBackupSite; use crate::remote::RemoteBackupSite;
use crate::remote::file::FileBackup; use crate::remote::file::FileBackup;
@ -43,22 +45,35 @@ pub fn backup_file(file_name: &str, world_path: &Path, backup_path: &Path) -> Re
pub fn backup_dir(dir_name: &str, world_path: &Path, backup_path: &Path) -> Result<u64> { pub fn backup_dir(dir_name: &str, world_path: &Path, backup_path: &Path) -> Result<u64> {
let src_dir = world_path.join(dir_name); let src_dir = world_path.join(dir_name);
if !src_dir.exists() { if !src_dir.exists() || !src_dir.is_dir() {
warn!("Directory '{dir_name}' does not exist in '{world_path:?}'"); warn!("Directory '{dir_name}' does not exist in '{world_path:?}'");
return Ok(0); return Ok(0);
} }
let backup_dir = backup_path.join(dir_name); let backup_dir_path = backup_path.join(dir_name);
create_dir(&backup_dir)?; create_dir(&backup_dir_path)?;
let mut file_count = 0; let mut file_count = 0;
for entry in src_dir.read_dir()? { for entry in src_dir.read_dir()? {
let entry = entry?; let entry = entry?;
let mut target = backup_dir.clone(); let mut target = backup_dir_path.clone();
target.push(entry.file_name());
copy(entry.path(), target)?; if entry.path().is_dir() {
file_count += 1; 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) Ok(file_count)
@ -94,14 +109,15 @@ pub fn backup_region(
let entry = entry?; let entry = entry?;
let file_name = entry.file_name().to_str().unwrap().to_string(); let file_name = entry.file_name().to_str().unwrap().to_string();
if let Ok(region) = Region::try_from(file_name) { if let Ok(region) = Region::try_from(file_name)
if region.x.abs() <= save_radius && region.y.abs() <= save_radius { && region.x.abs() <= save_radius
let mut target = backup_dir.clone(); && region.y.abs() <= save_radius
target.push(entry.file_name()); {
let mut target = backup_dir.clone();
target.push(entry.file_name());
copy(entry.path(), target)?; copy(entry.path(), target)?;
count += 1; count += 1;
}
} }
} }
@ -270,7 +286,7 @@ pub fn do_remote_backup(
/// * `cfg` - config file /// * `cfg` - config file
pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> { pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> {
let server_base_dir = cfg.backup.minecraft_dir.clone(); 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 time_str = Utc::now().format("%d-%m-%y_%H.%M.%S").to_string();
let backup_name = format!("{time_str}_backup.tar.gz"); let backup_name = format!("{time_str}_backup.tar.gz");
let mut output_archive = match output { let mut output_archive = match output {
@ -284,15 +300,21 @@ pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> {
create_dir_all(tmp_dir.clone())?; create_dir_all(tmp_dir.clone())?;
let timer = Instant::now();
send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg); send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg);
backup_worlds(&cfg, server_base_dir, worlds, &tmp_dir).map_err(|e| { let backup_res = if let Some(worlds) = &cfg.world_config {
send_webhook("Failed to copy worlds to backup location", &cfg); backup_worlds(&cfg, server_base_dir, worlds, &tmp_dir)
error!("Failed to copy worlds: {e}"); } else if let Some(world_config) = &cfg.world_26_plus_config {
e 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| { compress_backup(&tmp_dir, &output_archive).map_err(|e| {
send_webhook("Failed to compress backup", &cfg); send_webhook("Failed to compress backup", &cfg);
@ -302,8 +324,7 @@ pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> {
remove_dir_all(&tmp_dir)?; remove_dir_all(&tmp_dir)?;
let mut local_backup = let mut local_backup = FileBackup::new(&cfg.backup.output_config, cfg.backup.backups_to_keep)?;
FileBackup::new(&cfg.backup.output_config, cfg.backup.backups_to_keep).unwrap();
match local_backup.cleanup() { match local_backup.cleanup() {
Ok(backups_removed) => { Ok(backups_removed) => {
@ -342,10 +363,81 @@ pub fn do_backup(cfg: AlbatrossConfig, output: Option<PathBuf>) -> Result<()> {
Ok(()) 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( fn backup_worlds(
cfg: &AlbatrossConfig, cfg: &AlbatrossConfig,
server_base_dir: PathBuf, server_base_dir: PathBuf,
worlds: Vec<WorldConfig>, worlds: &[WorldConfig],
tmp_dir: &Path, tmp_dir: &Path,
) -> Result<()> { ) -> Result<()> {
for world in worlds { for world in worlds {
@ -360,15 +452,15 @@ fn backup_worlds(
let webhook_msg = match world_type { let webhook_msg = match world_type {
WorldType::Overworld => { WorldType::Overworld => {
let (region_count, player_count) = let (region_count, player_count) =
backup_overworld(&world_dir.clone(), tmp_dir, &world)?; backup_overworld(&world_dir.clone(), tmp_dir, world)?;
format!("{region_count} regions and {player_count} player files backed up.") format!("{region_count} regions and {player_count} player files backed up.")
} }
WorldType::Nether => { WorldType::Nether => {
let region_count = backup_nether(&world_dir, tmp_dir, &world)?; let region_count = backup_nether(&world_dir, tmp_dir, world)?;
format!("{region_count} regions backed up.") format!("{region_count} regions backed up.")
} }
WorldType::End => { WorldType::End => {
let region_count = backup_end(&world_dir, tmp_dir, &world)?; let region_count = backup_end(&world_dir, tmp_dir, world)?;
format!("{region_count} regions backed up.") format!("{region_count} regions backed up.")
} }
}; };

View File

@ -64,11 +64,19 @@ pub struct RemoteBackupConfig {
pub file: Option<FileConfig>, 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 /// Configs
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct AlbatrossConfig { pub struct AlbatrossConfig {
pub backup: BackupConfig, pub backup: BackupConfig,
pub world_config: Option<Vec<WorldConfig>>, pub world_config: Option<Vec<WorldConfig>>,
pub world_26_plus_config: Option<World26PlusConfig>,
pub remote: Option<RemoteBackupConfig>, pub remote: Option<RemoteBackupConfig>,
} }

View File

@ -11,6 +11,7 @@ pub enum AlbatrossError {
ChronoParseError(chrono::ParseError), ChronoParseError(chrono::ParseError),
NoSSHAuth, NoSSHAuth,
FTPError(ftp::FtpError), FTPError(ftp::FtpError),
MissingConfig,
} }
impl std::error::Error for AlbatrossError {} impl std::error::Error for AlbatrossError {}
@ -27,6 +28,7 @@ impl std::fmt::Display for AlbatrossError {
AlbatrossError::ChronoParseError(e) => write!(f, "Unable to parse time: {e}"), AlbatrossError::ChronoParseError(e) => write!(f, "Unable to parse time: {e}"),
AlbatrossError::NoSSHAuth => write!(f, "No SSH auth methods provided in the config"), AlbatrossError::NoSSHAuth => write!(f, "No SSH auth methods provided in the config"),
AlbatrossError::FTPError(e) => write!(f, "FTP error: {e}"), AlbatrossError::FTPError(e) => write!(f, "FTP error: {e}"),
AlbatrossError::MissingConfig => write!(f, "Missing world config"),
} }
} }
} }

View File

@ -74,64 +74,60 @@ fn main() {
let cfg = AlbatrossConfig::new(opt.config_path.into_os_string().to_str().unwrap()) let cfg = AlbatrossConfig::new(opt.config_path.into_os_string().to_str().unwrap())
.expect("Config error"); .expect("Config error");
if cfg.world_config.is_some() { match opt.sub_command {
match opt.sub_command { SubCommand::Backup { output } => {
SubCommand::Backup { output } => { info!("Starting backup");
info!("Starting backup"); match do_backup(cfg, output) {
match do_backup(cfg, output) { Ok(_) => info!("Backup complete!"),
Ok(_) => info!("Backup complete!"), Err(e) => info!("Error doing backup: {e:?}"),
Err(e) => info!("Error doing backup: {e:?}"), };
}; }
} SubCommand::Export {
SubCommand::Export { input_backup,
input_backup, output,
output, } => {
} => { info!("Starting export");
info!("Starting export"); match convert_backup_to_sp(&cfg, &input_backup, &output) {
match convert_backup_to_sp(&cfg, &input_backup, &output) { Ok(_) => info!("Export complete!"),
Ok(_) => info!("Export complete!"), Err(e) => info!("Error exporting backup: {e:?}"),
Err(e) => info!("Error exporting backup: {e:?}"), };
}; }
} SubCommand::Restore {
SubCommand::Restore { server_directory,
server_directory, world_name,
world_name, backup_path,
backup_path, chunk,
chunk, upper_bound,
upper_bound, } => {
} => { info!("Starting restore");
info!("Starting restore");
let server_directory = match server_directory { let server_directory = match server_directory {
Some(dir) => dir, Some(dir) => dir,
None => cfg.backup.minecraft_dir, None => cfg.backup.minecraft_dir,
}; };
if let Some(upper_bound) = upper_bound { if let Some(upper_bound) = upper_bound {
match restore_range_from_backup( match restore_range_from_backup(
world_name.as_str(), world_name.as_str(),
chunk, chunk,
upper_bound, upper_bound,
&backup_path, &backup_path,
&server_directory, &server_directory,
) { ) {
Ok(count) => info!("Restored {count} chunks!"), Ok(count) => info!("Restored {count} chunks!"),
Err(e) => info!("Error restoring backup: {e:?}"), Err(e) => info!("Error restoring backup: {e:?}"),
}; };
} else { } else {
match restore_chunk_from_backup( match restore_chunk_from_backup(
world_name.as_str(), world_name.as_str(),
chunk, chunk,
&backup_path, &backup_path,
&server_directory, &server_directory,
) { ) {
Ok(_) => info!("Restored chunk!"), Ok(_) => info!("Restored chunk!"),
Err(e) => info!("Error restoring backup: {e:?}"), Err(e) => info!("Error restoring backup: {e:?}"),
}; };
}
} }
} }
} else {
info!("No worlds specified in config file!")
} }
} }