diff --git a/.cargo/config.toml b/.cargo/config.toml index 3056b64..4b1cf60 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,7 @@ [alias] -runs = "run -- paper --config data/config --backup data/backups --world data/worlds --jar data/paper.jar" +runs = "run -- --config data/config --backup data/backups --world data/worlds --layers 2min,2,4,4;3min,3,2,2" +runrs = "run --release -- --config data/config --backup data/backups --world data/worlds --layers 2min,2,4,4;3min,3,2,2" + +[target.aarch64-unknown-linux-musl] +linker = "aarch64-linux-gnu-gcc" +runner = "qemu-aarch64" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1917669 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* + +!Cargo.toml +!Cargo.lock +!src/ diff --git a/.gitignore b/.gitignore index 4259b1b..0f92b30 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ target/ # testing files *.jar -data/ +data*/ +*.log diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml index a1722be..357ab76 100644 --- a/.woodpecker/build.yml +++ b/.woodpecker/build.yml @@ -5,17 +5,17 @@ matrix: platform: "linux/${ARCH}" -branches: - exclude: [main] +when: + branch: + exclude: [main] + event: push -pipeline: +steps: build: - image: 'rust:1.70-alpine3.18' + image: 'rust:1.71-alpine3.18' commands: - apk add --no-cache build-base - cargo build --verbose - cargo test --verbose # Binaries, even debug ones, should be statically compiled - '[ "$(readelf -d target/debug/alex | grep NEEDED | wc -l)" = 0 ]' - when: - event: [push] diff --git a/.woodpecker/clippy.yml b/.woodpecker/clippy.yml index 61b150d..7bdc59c 100644 --- a/.woodpecker/clippy.yml +++ b/.woodpecker/clippy.yml @@ -1,13 +1,13 @@ platform: 'linux/amd64' -branches: - exclude: [main] +when: + branch: + exclude: [ main ] + event: push -pipeline: +steps: clippy: - image: 'rust:1.70' + image: 'rust:1.71' commands: - rustup component add clippy - cargo clippy -- --no-deps -Dwarnings - when: - event: [push] diff --git a/.woodpecker/lint.yml b/.woodpecker/lint.yml index b74d26d..be6f331 100644 --- a/.woodpecker/lint.yml +++ b/.woodpecker/lint.yml @@ -1,13 +1,13 @@ platform: 'linux/amd64' -branches: - exclude: [main] +when: + branch: + exclude: [ main ] + event: push -pipeline: +steps: lint: - image: 'rust:1.70' + image: 'rust:1.71' commands: - rustup component add rustfmt - cargo fmt -- --check - when: - event: [push] diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index f7d44db..32524de 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -4,19 +4,19 @@ matrix: - 'linux/arm64' platform: ${PLATFORM} -branches: [ main ] -pipeline: +when: + event: tag + +steps: build: - image: 'rust:1.70-alpine3.18' + image: 'rust:1.71-alpine3.18' commands: - apk add --no-cache build-base - cargo build --release --verbose # Ensure the release binary is also statically compiled - '[ "$(readelf -d target/release/alex | grep NEEDED | wc -l)" = 0 ]' - du -h target/release/alex - when: - event: tag publish: image: 'curlimages/curl' @@ -28,5 +28,3 @@ pipeline: --user "Chewing_Bever:$GITEA_PASSWORD" --upload-file target/release/alex https://git.rustybever.be/api/packages/Chewing_Bever/generic/alex/"${CI_COMMIT_TAG}"/alex-"$(echo '${PLATFORM}' | sed 's:/:-:g')" - when: - event: tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b969cb..8e1ad49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://git.rustybever.be/Chewing_Bever/alex/src/branch/dev) +### Added + +* Debian packages are now available in the [package registry](https://git.rustybever.be/Chewing_Bever/alex/packages) + +## [0.5.0](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.5.0) + +### Added + +* CLI commands to interact with the PaperMC API + * list and view available Minecraft versions and builds per version + * Download jars to simplify manual server updating + +## [0.4.2](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.4.2) + +### Fixed + +* Fix bug where JSON metadata file can be corrupted if crash occurs while + writing (data is now written to a temporary file before atomically renaming) + +## [0.4.1](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.4.1) + +### Changed + +* Moved PKGBUILD to separate repo +* Properly update lock file + +## [0.4.0](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.4.0) + +### Added + +* Extract command for working with the output of export +* Arch packages are now published to my bur repo +* Allow passing configuration variables from TOML file + +### Changed + +* Export command no longer reads backups that do not contribute to the final + state +* Running backups no longer block stdin input or shutdown +* Env vars `ALEX_CONFIG_DIR`, `ALEX_WORLD_DIR` and `ALEX_BACKUP_DIR` renamed to + `ALEX_CONFIG`, `ALEX_WORLD` and `ALEX_BACKUP` respectively + +## [0.3.1](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.3.1) + +### Added + +* Export command to export any backup as a new full backup + +## [0.3.0](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.3.0) + +### Added + +* Incremental backups + * Chain length describes how many incremental backups to create from the + same full backup + * "backups to keep" has been replaced by "chains to keep" +* Server type & version and backup size are now stored as metadata in the + metadata file +* Backup layers + * Store multiple chains of backups in parallel, configuring each with + different parameters (son-father-grandfather principle) +* CLI commands for creating, restoring & listing backups + +### Changed + +* Running the server now uses the `run` CLI subcommand +* `server_type` and `server_version` arguments are now optional flags + +### Removed + +* `max_backups` setting + +## [0.2.2](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.2.2) + +### Fixed + +* Use correct env var for backup directory + +## [0.2.1](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.2.1) + +### Added + +* `--dry` flag to inspect command that will be run + +### Changed + +* JVM flags now narrowly follow Aikar's specifications + +## [0.2.0](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.2.0) + +### Added + +* Rudimentary signal handling for gently stopping server + * A single stop signal will trigger the Java process to shut down, but Alex + still expects to be run from a utility such as dumb-init +* Properly back up entire config directory +* Inject Java optimization flags + ## [0.1.1](https://git.rustybever.be/Chewing_Bever/alex/src/tag/0.1.1) ### Changed diff --git a/Cargo.lock b/Cargo.lock index 5953204..a71e4e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,29 +1,27 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "alex" -version = "0.1.0" +version = "0.5.0" dependencies = [ + "backup", "chrono", "clap", - "flate2", - "tar", + "figment", + "papermc-api", + "serde", + "signal-hook", + "ureq", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -35,127 +33,167 @@ dependencies = [ [[package]] name = "anstream" -version = "0.3.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - "is-terminal", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.0" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "1.0.1" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backup" +version = "0.5.0" +dependencies = [ + "chrono", + "flate2", + "serde", + "serde_json", + "tar", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.13.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.0.79" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.26" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", - "time", + "serde", "wasm-bindgen", - "winapi", + "windows-link", ] [[package]] name = "clap" -version = "4.3.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ed2379f8603fa2b7509891660e802b88c70a79a6427a70abb5968054de2c28" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", - "once_cell", ] [[package]] name = "clap_builder" -version = "4.3.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72394f3339a76daf211e57d4bcb374410f3965dcc606dd0e03738c7888766980" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", - "bitflags", "clap_lex", "strsim", ] [[package]] name = "clap_derive" -version = "4.3.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e9ef9a08ee1c0e1f2e162121665ac45ac3783b0f897db7244ae75ad9a8f65b" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -165,98 +203,207 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.5.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] -name = "errno" -version = "0.3.1" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys", + "powerfmt", ] [[package]] -name = "errno-dragonfly" -version = "0.1.2" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "cc", "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", ] [[package]] name = "filetime" -version = "0.2.21" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", - "redox_syscall", - "windows-sys", + "libredox", ] [[package]] -name = "flate2" -version = "1.0.26" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] -name = "heck" -version = "0.4.1" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "hermit-abi" -version = "0.3.1" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "iana-time-zone" -version = "0.1.56" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows", + "windows-core", ] [[package]] @@ -269,131 +416,512 @@ dependencies = [ ] [[package]] -name = "io-lifetimes" -version = "1.0.11" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "hermit-abi", - "libc", - "windows-sys", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "is-terminal" -version = "0.4.7" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "hermit-abi", - "io-lifetimes", - "rustix", - "windows-sys", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" -version = "0.3.63" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "once_cell", "wasm-bindgen", ] [[package]] name = "libc" -version = "0.2.144" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] -name = "linux-raw-sys" -version = "0.3.8" +name = "libredox" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - -[[package]] -name = "log" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" - -[[package]] -name = "miniz_oxide" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "adler", + "bitflags", + "libc", + "plain", + "redox_syscall", ] [[package]] -name = "num-traits" -version = "0.2.15" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" -version = "1.17.2" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "papermc-api" +version = "0.5.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "ureq", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.59" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] -name = "quote" -version = "1.0.28" +name = "proc-macro2-diagnostics" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" -version = "0.37.19" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", - "io-lifetimes", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "strsim" -version = "0.10.0" +name = "rustls" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.18" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -401,10 +929,21 @@ dependencies = [ ] [[package]] -name = "tar" -version = "0.4.38" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b55807c0344e1e6c04d7c965f5289c39a8d94ae23ed5c0b57aabac549f871c6" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -413,63 +952,199 @@ dependencies = [ [[package]] name = "time" -version = "0.1.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ - "libc", - "wasi", - "winapi", + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", ] [[package]] name = "unicode-ident" -version = "1.0.9" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "utf8parse" -version = "0.2.1" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" +name = "ureq" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - -[[package]] -name = "wasm-bindgen" -version = "0.2.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.86" +name = "ureq-proto" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "bumpalo", + "base64", + "http", + "httparse", "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.86" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -477,72 +1152,122 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.86" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.86" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "unicode-ident", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "webpki-roots" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] [[package]] -name = "windows" -version = "0.48.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets", + "windows-link", ] [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -551,51 +1276,168 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xattr" -version = "0.2.3" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", + "rustix", ] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index dd2f79b..f834642 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,20 @@ -[package] -name = "alex" -version = "0.1.0" -description = "Wrapper around Minecraft server processes, designed to complement Docker image installations." +[workspace] +resolver = "2" +members = [ + 'backup', + 'alex', + 'papermc-api' +] + +[workspace.package] +version = "0.5.0" authors = ["Jef Roosens"] edition = "2021" +license-file = "LICENSE" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -# Used for creating tarballs for backups -tar = "0.4.38" -# Used to compress said tarballs using gzip -flate2 = "1.0.26" -# Used for backup filenames -chrono = "0.4.26" -clap = { version = "4.3.1", features = ["derive", "env"] } +[workspace.dependencies] +chrono = { version = "0.4.26", features = ["serde"] } +serde = { version = "1.0.164", features = ["derive"] } [profile.release] lto = "fat" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..819cbf2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +FROM rust:1.70-alpine3.18 AS builder + +ARG DI_VER=1.2.5 + +WORKDIR /app + +COPY . ./ + +RUN apk add --no-cache build-base unzip curl && \ + curl -Lo - "https://github.com/Yelp/dumb-init/archive/refs/tags/v${DI_VER}.tar.gz" | tar -xzf - && \ + cd "dumb-init-${DI_VER}" && \ + make SHELL=/bin/sh && \ + mv dumb-init .. + +RUN cargo build && \ + [ "$(readelf -d target/debug/alex | grep NEEDED | wc -l)" = 0 ] + + +# We use ${:-} instead of a default value because the argument is always passed +# to the build, it'll just be blank most likely +FROM eclipse-temurin:18-jre-alpine + +# Build arguments +ARG MC_VERSION=1.19.4 +ARG PAPERMC_VERSION=525 + +RUN addgroup -Sg 1000 paper && \ + adduser -SHG paper -u 1000 paper + +# Create worlds and config directory +WORKDIR /app +RUN mkdir -p worlds config/cache backups + +# Download server file +ADD "https://papermc.io/api/v2/projects/paper/versions/$MC_VERSION/builds/$PAPERMC_VERSION/downloads/paper-$MC_VERSION-$PAPERMC_VERSION.jar" server.jar + +# Make sure the server user can access all necessary folders +RUN chown -R paper:paper /app + +# Store the cache in an anonymous volume, which means it won't get stored in the other volumes +VOLUME /app/config/cache +VOLUME /app/backups + +COPY --from=builder /app/dumb-init /bin/dumb-init +COPY --from=builder /app/target/debug/alex /bin/alex + +RUN chmod +x /bin/alex + +# Default value to keep users from eating up all ram accidentally +ENV ALEX_CONFIG=/app/config \ + ALEX_WORLD=/app/worlds \ + ALEX_BACKUP=/app/backups \ + ALEX_SERVER=paper \ + ALEX_XMS=1024 \ + ALEX_XMX=2048 \ + ALEX_JAR=/app/server.jar \ + ALEX_SERVER_VERSION="${MC_VERSION}-${PAPERMC_VERSION}" \ + ALEX_LAYERS="2min,2,4,4;3min,3,2,2" + +# Document exposed ports +EXPOSE 25565 + +# Switch to non-root user +USER paper:paper + +ENTRYPOINT ["/bin/dumb-init", "--"] +CMD ["/bin/alex", "run"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..54180ad --- /dev/null +++ b/Justfile @@ -0,0 +1,81 @@ +# Build the local development build +[group('build')] +build: + cargo build --frozen --workspace +alias b := build + +# Build release binaries for the supported architectures +[group('build')] +build-release: + cargo build \ + --release \ + --frozen \ + --workspace \ + --target x86_64-unknown-linux-musl \ + --target aarch64-unknown-linux-musl + +# Run all tests in the workspace +test: + cargo test --frozen --workspace +alias t := test + +# Run cargofmt and clippy +check: + cargo fmt --check --all + cargo clippy \ + --frozen \ + --all -- \ + --no-deps \ + --deny 'clippy::all' +alias c := check +alias lint := check + +fetch: + cargo fetch --locked + +clean: + cargo clean + +doc: + cargo doc --workspace --frozen + +run: + mkdir -p data + cargo run --frozen --package alex -- run \ + --config data/config \ + --backup data/backups \ + --world data/worlds \ + --jar ./paper-1.21.5-77.jar \ + --java '/usr/lib/jvm/java-21-openjdk/bin/java' \ + --layers '2min,2,4,4;3min,3,2,2' + +# Package the static release binaries as a Debian package +[group('package')] +package-deb: build-release + cargo deb \ + --package alex \ + --frozen \ + --no-build \ + --target x86_64-unknown-linux-musl \ + --target aarch64-unknown-linux-musl + +# Publish the binaries and packages for a new release +[group('package')] +publish-release tag: build-release package-deb + # Check the binaries are proper static binaries + [ "$(readelf -d target/x86_64-unknown-linux-musl/release/alex | grep NEEDED | wc -l)" = 0 ] + [ "$(readelf -d target/aarch64-unknown-linux-musl/release/alex | grep NEEDED | wc -l)" = 0 ] + + curl \ + --parallel --fail-early \ + --netrc --upload-file target/x86_64-unknown-linux-musl/release/alex \ + https://git.rustybever.be/api/packages/Chewing_Bever/generic/alex/"{{ tag }}"/alex-linux-amd64 \ + --next \ + --netrc --upload-file target/aarch64-unknown-linux-musl/release/alex \ + https://git.rustybever.be/api/packages/Chewing_Bever/generic/alex/"{{ tag }}"/alex-linux-arm64 \ + --next \ + --netrc --upload-file target/debian/alex_{{ tag }}-1_amd64.deb \ + https://git.rustybever.be/api/packages/Chewing_Bever/debian/pool/any/main/upload \ + --next \ + --netrc --upload-file target/debian/alex_{{ tag }}-1_arm64.deb \ + https://git.rustybever.be/api/packages/Chewing_Bever/debian/pool/any/main/upload > /dev/null diff --git a/README.md b/README.md index f6215c3..979bd1e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,135 @@ -# mc-wrapper +# Alex -A wrapper around a standard Minecraft server, written in Rust. \ No newline at end of file +Alex is a wrapper around a typical Minecraft server process. It acts as the +parent process, and sits in between the user's input and the server's stdin. +This allows Alex to support additional commands that execute Rust code, notably +creating periodic backups. + +## Installation + +Alex is distributed as statically compiled binaries for Linux amd64 and arm64. +These can be found +[here](https://git.rustybever.be/Chewing_Bever/alex/packages). + +### Arch + +Arch users can install prebuilt `x86_64` & `aarch64` packages from my `bur` +repository. Add the following at the bottom of your `pacman.conf`: + +```toml +[bur] +Server = https://arch.r8r.be/$repo/$arch +SigLevel = Optional +``` + +If you prefer building the package yourself, the PKGBUILD can be found +[here](https://git.rustybever.be/bur/alex-mc). + +### Dockerfiles + +You can easily install alex in your Docker images by letting Docker download it +for you. Add the following to your Dockerfile (replace with your required +version & architecture): + +```dockerfile +ADD "https://git.rustybever.be/api/packages/Chewing_Bever/generic/alex/0.2.2/alex-linux-amd64" /bin/alex +``` + +## Why + +The primary usecase for this is backups. A common problem I've had with +Minecraft backups is that they fail, because the server is writing to one of +the region files as the backup is being created. Alex solves this be sending +`save-off` and `save-all` to the server, before creating the tarball. +Afterwards, saving is enabled again with `save-on`. + +## Features + +* Create safe backups as gzip-compressed tarballs using the `backup` command +* Automatically create backups periodically +* Properly configures the process (working directory, optimisation flags) +* Configure everything as CLI arguments or environment variables + +## Configuration + +Most information can be retrieved easily by looking at the help command: + +``` +Wrapper around Minecraft server processes, designed to complement Docker image installations. + +Usage: alex [OPTIONS] + +Commands: + run Run the server + backup Interact with the backup system without starting a server + help Print this message or the help of the given subcommand(s) + +Options: + --config + Directory where configs are stored, and where the server will run [env: ALEX_CONFIG_DIR=] [default: .] + --world + Directory where world files will be saved [env: ALEX_WORLD_DIR=] [default: ../worlds] + --backup + Directory where backups will be stored [env: ALEX_BACKUP_DIR=] [default: ../backups] + --layers + What backup layers to employ, provided as a list of tuples name,frequency,chains,chain_len delimited by semicolons (;) [env: ALEX_LAYERS=] + --server + Type of server [env: ALEX_SERVER=] [default: unknown] [possible values: unknown, paper, forge, vanilla] + --server-version + Version string for the server, e.g. 1.19.4-545 [env: ALEX_SERVER_VERSION=] [default: ] + -h, --help + Print help + -V, --version + Print version + +``` + +### Choosing layer parameters + +One part of the configuration that does require some clarification is the layer +system. Alex can manage an arbitrary number of backup layers, each having its +own configuration. These layers can either use incremental or full backups, +depending on how they're configured. + +These layers mostly correspond to the grandfather-father-son backup rotation +scheme. For example, one could have a layer that creates incremental backups +every 30 minutes, which are stored for 24 hours. This gives you 24 hours of +granular rollback in case your server suffers a crash. A second layer might +create a full backup every 24 hours, with backups being stored for 7 days. This +gives you 7 days worth of backups with the granularity of 24 hours. This +approach allows for greater versatility, while not having to store a large +amount of data. Thanks to incremental backups, frequent backups don't have to +take long at all. + +A layer consists of 4 pieces of metadata: + +* A name, which will be used in the file system and the in-game notifications +* The frequency, which describes in minutes how frequently a backup should be + created +* How many chains should be kept at all times +* How long each chain should be + +These last two require some clarification. In Alex, a "chain" describes an +initial full backup and zero or more incremental backups that are created from +that initial full backup. This concept exists because an incremental backup has +no real meaning if its ancestors are not known. To restore one of these chains, +all backups in the chain need to be restored in-order. Note that a chain length +of 1 disables incremental backups entirely. + +How many backups to keep is defined by how many chains should be stored. +Because an incremental backup needs to have its ancestors in order to be +restored, we can't simply "keep the last n backups", as this would break these +chains. Therefore, you configure how many backups to store using these chains. + +For example, if you configure a layer to store 5 chains of length 4, you will +have 20 archive files on disk, namely 5 full backups and 15 incremental +backups. Note that Alex applies these rules to *full* chains. An in-progress +chain does not count towards this total. Therefore, you can have up to `n-1` +additional archive files, with `n` being the chain length, on disk. + +To look at it from another perspective, say we wish to have a granularity of 30 +minutes for a timespan of 24 hours. Then we could configure the layer to only +save a single chain, with a chain length of 48. If we prefer to have a few full +backups instead of a long chain of incremental backups, we could instead use a +chain length of 12 and store 4 chains. Either way, the total comes out to 48, +which spans 24 hours if we make a backup every 30 minutes. diff --git a/alex-example.toml b/alex-example.toml new file mode 100644 index 0000000..3157870 --- /dev/null +++ b/alex-example.toml @@ -0,0 +1,18 @@ +config = "data/config" +world = "data/worlds" +backup = "data/backups" +server = "paper" +jar = './paper-1.21.5-77.jar' +java = '/usr/lib/jvm/java-21-openjdk/bin/java' + +[[layers]] +name = "2min" +frequency = 2 +chains = 4 +chain_len = 4 + +[[layers]] +name = "3min" +frequency = 3 +chains = 2 +chain_len = 2 diff --git a/alex/Cargo.lock b/alex/Cargo.lock new file mode 100644 index 0000000..e98ba32 --- /dev/null +++ b/alex/Cargo.lock @@ -0,0 +1,871 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "alex" +version = "0.1.0" +dependencies = [ + "backup", + "chrono", + "clap", + "figment", + "serde", + "signal-hook", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backup" +version = "0.4.1" +dependencies = [ + "chrono", + "flate2", + "serde", + "serde_json", + "tar", +] + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytemuck" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" + +[[package]] +name = "cc" +version = "1.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +dependencies = [ + "memchr", +] + +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/alex/Cargo.toml b/alex/Cargo.toml new file mode 100644 index 0000000..ff5df75 --- /dev/null +++ b/alex/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "alex" +description = "Wrapper around Minecraft server processes, designed to complement Docker image installations." +version.workspace = true +edition.workspace = true +authors.workspace = true +license-file.workspace = true + +[dependencies] +backup = { path = "../backup" } +papermc-api = { path = "../papermc-api" } + +chrono.workspace = true +serde.workspace = true + +clap = { version = "4.5.37", features = ["derive", "env"] } +signal-hook = "0.3.15" +figment = { version = "0.10.10", features = ["env", "toml"] } +ureq = "3.3.0" diff --git a/alex/Justfile b/alex/Justfile new file mode 100644 index 0000000..cc02572 --- /dev/null +++ b/alex/Justfile @@ -0,0 +1,16 @@ +build: + cargo build --frozen +alias b := build + +test: + cargo test --frozen +alias t := test + +check: + cargo fmt --check + cargo clippy \ + --frozen \ + -- \ + --no-deps \ + --deny 'clippy::all' +alias c := check diff --git a/alex/src/cli/backup.rs b/alex/src/cli/backup.rs new file mode 100644 index 0000000..a587a3d --- /dev/null +++ b/alex/src/cli/backup.rs @@ -0,0 +1,303 @@ +use std::io; +use std::path::{Path, PathBuf}; + +use chrono::{TimeZone, Utc}; +use clap::{Args, Subcommand}; + +use crate::other; +use backup::Backup; + +#[derive(Subcommand)] +pub enum BackupCommands { + /// List all tracked backups + /// + /// Note that this will only list backups for the layers currently configured, and will ignore + /// any other layers also present in the backup directory. + List(BackupListArgs), + /// Manually create a new backup + /// + /// Note that backups created using this command will count towards the length of a chain, and + /// can therefore shorten how far back in time your backups will be stored. + Create(BackupCreateArgs), + /// Restore a backup + /// + /// This command will restore the selected backup by extracting its entire chain up to and + /// including the requested backup in-order. + Restore(BackupRestoreArgs), + /// Export a backup into a full archive + /// + /// Just like the restore command, this will extract each backup from the chain up to and + /// including the requested backup, but instead of writing the files to disk, they will be + /// recompressed into a new tarball, resulting in a new tarball containing a full backup. + Export(BackupExportArgs), + /// Extract an archive file, which is assumed to be a full backup. + /// + /// This command mostly exists as a convenience method for working with the output of `export`. + Extract(BackupExtractArgs), +} + +#[derive(Args)] +pub struct BackupArgs { + #[command(subcommand)] + pub command: BackupCommands, +} + +#[derive(Args)] +pub struct BackupCreateArgs { + /// What layer to create a backup in + layer: String, +} + +#[derive(Args)] +pub struct BackupListArgs { + /// What layer to list + layer: Option, +} + +#[derive(Args)] +pub struct BackupRestoreArgs { + /// Path to the backup inside the backup directory to restore + path: PathBuf, + /// Directory to store config in + output_config: PathBuf, + /// Directory to store worlds in + output_worlds: PathBuf, + /// Whether to overwrite the contents of the output directories + /// + /// If set, the output directories will be completely cleared before trying to restore the + /// backup. + #[arg(short, long, default_value_t = false)] + force: bool, + /// Create output directories if they don't exist + #[arg(short, long, default_value_t = false)] + make: bool, +} + +#[derive(Args)] +pub struct BackupExportArgs { + /// Path to the backup inside the backup directory to export + path: PathBuf, + /// Path to store the exported archive + output: PathBuf, + /// Create output directories if they don't exist + #[arg(short, long, default_value_t = false)] + make: bool, +} + +#[derive(Args)] +pub struct BackupExtractArgs { + /// Path to the backup to extract + path: PathBuf, + /// Directory to store config in + output_config: PathBuf, + /// Directory to store worlds in + output_worlds: PathBuf, + /// Whether to overwrite the contents of the output directories + /// + /// If set, the output directories will be completely cleared before trying to restore the + /// backup. + #[arg(short, long, default_value_t = false)] + force: bool, + /// Create output directories if they don't exist + #[arg(short, long, default_value_t = false)] + make: bool, +} + +impl BackupArgs { + pub fn run(&self, cli: &super::Config) -> io::Result<()> { + match &self.command { + BackupCommands::Create(args) => args.run(cli), + BackupCommands::List(args) => args.run(cli), + BackupCommands::Restore(args) => args.run(cli), + BackupCommands::Export(args) => args.run(cli), + BackupCommands::Extract(args) => args.run(cli), + } + } +} + +impl BackupCreateArgs { + pub fn run(&self, cli: &super::Config) -> io::Result<()> { + let mut meta = cli.meta()?; + + if let Some(res) = meta.create_backup(&self.layer) { + res + } else { + Err(io::Error::new(io::ErrorKind::Other, "Unknown layer")) + } + } +} + +impl BackupListArgs { + pub fn run(&self, cli: &super::Config) -> io::Result<()> { + let meta = cli.meta()?; + + // A bit scuffed? Sure + for (name, manager) in meta + .managers() + .iter() + .filter(|(name, _)| self.layer.is_none() || &self.layer.as_ref().unwrap() == name) + { + println!("{}", name); + + for chain in manager.chains().iter().filter(|c| !c.is_empty()) { + let mut iter = chain.iter(); + println!(" {}", iter.next().unwrap()); + + for backup in iter { + println!(" {}", backup); + } + } + } + + Ok(()) + } +} + +/// Tries to parse the given path as the path to a backup inside the backup directory with a +/// formatted timestamp. +fn parse_backup_path( + backup_dir: &Path, + backup_path: &Path, +) -> io::Result<(String, chrono::DateTime)> { + if !backup_path.starts_with(backup_dir) { + return Err(other("Provided file is not inside the backup directory.")); + } + + let layer = if let Some(parent) = backup_path.parent() { + // Backup files should be stored nested inside a layer's folder + if parent != backup_dir { + parent.file_name().unwrap().to_string_lossy() + } else { + return Err(other("Invalid path.")); + } + } else { + return Err(other("Invalid path.")); + }; + + let timestamp = if let Some(filename) = backup_path.file_name() { + Utc.datetime_from_str(&filename.to_string_lossy(), Backup::FILENAME_FORMAT) + .map_err(|_| other("Invalid filename."))? + } else { + return Err(other("Invalid filename.")); + }; + + Ok((layer.to_string(), timestamp)) +} + +impl BackupRestoreArgs { + pub fn run(&self, cli: &super::Config) -> io::Result<()> { + let backup_dir = cli.backup.canonicalize()?; + + // Create directories if needed + if self.make { + std::fs::create_dir_all(&self.output_config)?; + std::fs::create_dir_all(&self.output_worlds)?; + } + + let output_config = self.output_config.canonicalize()?; + let output_worlds = self.output_worlds.canonicalize()?; + + // Parse input path + let backup_path = self.path.canonicalize()?; + let (layer, timestamp) = parse_backup_path(&backup_dir, &backup_path)?; + + let meta = cli.meta()?; + + // Clear previous contents of directories + let mut entries = output_config + .read_dir()? + .chain(output_worlds.read_dir()?) + .peekable(); + + if entries.peek().is_some() && !self.force { + return Err(other("Output directories are not empty. If you wish to overwrite these contents, use the force flag.")); + } + + for entry in entries { + let path = entry?.path(); + + if path.is_dir() { + std::fs::remove_dir_all(path)?; + } else { + std::fs::remove_file(path)?; + } + } + + let dirs = vec![ + (PathBuf::from("config"), output_config), + (PathBuf::from("worlds"), output_worlds), + ]; + + // Restore the backup + if let Some(res) = meta.restore_backup(&layer, timestamp, &dirs) { + res + } else { + Err(other("Unknown layer")) + } + } +} + +impl BackupExportArgs { + pub fn run(&self, cli: &super::Config) -> io::Result<()> { + let backup_dir = cli.backup.canonicalize()?; + + if self.make { + if let Some(parent) = &self.output.parent() { + std::fs::create_dir_all(parent)?; + } + } + + // Parse input path + let backup_path = self.path.canonicalize()?; + let (layer, timestamp) = parse_backup_path(&backup_dir, &backup_path)?; + + let meta = cli.meta()?; + + if let Some(res) = meta.export_backup(&layer, timestamp, &self.output) { + res + } else { + Err(other("Unknown layer")) + } + } +} + +impl BackupExtractArgs { + pub fn run(&self, _cli: &super::Config) -> io::Result<()> { + // Create directories if needed + if self.make { + std::fs::create_dir_all(&self.output_config)?; + std::fs::create_dir_all(&self.output_worlds)?; + } + + let output_config = self.output_config.canonicalize()?; + let output_worlds = self.output_worlds.canonicalize()?; + let backup_path = self.path.canonicalize()?; + + // Clear previous contents of directories + let mut entries = output_config + .read_dir()? + .chain(output_worlds.read_dir()?) + .peekable(); + + if entries.peek().is_some() && !self.force { + return Err(other("Output directories are not empty. If you wish to overwrite these contents, use the force flag.")); + } + + for entry in entries { + let path = entry?.path(); + + if path.is_dir() { + std::fs::remove_dir_all(path)?; + } else { + std::fs::remove_file(path)?; + } + } + + let dirs = vec![ + (PathBuf::from("config"), output_config), + (PathBuf::from("worlds"), output_worlds), + ]; + + Backup::extract_archive(backup_path, &dirs) + } +} diff --git a/alex/src/cli/config.rs b/alex/src/cli/config.rs new file mode 100644 index 0000000..b940e96 --- /dev/null +++ b/alex/src/cli/config.rs @@ -0,0 +1,47 @@ +use std::{io, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::server::{Metadata, ServerType}; +use backup::{ManagerConfig, MetaManager}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Config { + pub config: PathBuf, + pub world: PathBuf, + pub backup: PathBuf, + pub layers: Vec, + pub server: ServerType, + pub server_version: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + config: PathBuf::from("."), + world: PathBuf::from("../worlds"), + backup: PathBuf::from("../backups"), + layers: Vec::new(), + server: ServerType::Unknown, + server_version: String::from(""), + } + } +} + +impl Config { + /// Convenience method to initialize backup manager from the cli arguments + pub fn meta(&self) -> io::Result> { + let metadata = Metadata { + server_type: self.server, + server_version: self.server_version.clone(), + }; + let dirs = vec![ + (PathBuf::from("config"), self.config.canonicalize()?), + (PathBuf::from("worlds"), self.world.canonicalize()?), + ]; + let mut meta = MetaManager::new(self.backup.canonicalize()?, dirs, metadata); + meta.add_all(&self.layers)?; + + Ok(meta) + } +} diff --git a/alex/src/cli/mod.rs b/alex/src/cli/mod.rs new file mode 100644 index 0000000..92724ea --- /dev/null +++ b/alex/src/cli/mod.rs @@ -0,0 +1,125 @@ +mod backup; +mod config; +mod papermc; +mod run; + +use std::{path::PathBuf, str::FromStr}; + +use clap::{Args, Parser, Subcommand}; +use figment::{ + providers::{Env, Format, Serialized, Toml}, + Figment, +}; +use serde::{Deserialize, Serialize}; + +use crate::server::ServerType; +use ::backup::ManagerConfig; +use backup::BackupArgs; +use config::Config; +use run::RunCli; + +#[derive(Parser, Serialize)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + #[serde(skip)] + pub command: Commands, + + /// Path to a TOML configuration file + #[arg(long = "config-file", global = true)] + pub config_file: Option, + + #[command(flatten)] + pub args: CliArgs, +} + +#[derive(Args, Serialize, Deserialize, Clone)] +pub struct CliArgs { + /// Directory where configs are stored, and where the server will run + #[arg(long, value_name = "CONFIG_DIR", global = true)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub config: Option, + + /// Directory where world files will be saved + #[arg(long, value_name = "WORLD_DIR", global = true)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub world: Option, + + /// Directory where backups will be stored + #[arg(long, value_name = "BACKUP_DIR", global = true)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub backup: Option, + + /// What backup layers to employ, provided as a list of tuples name,frequency,chains,chain_len + /// delimited by semicolons (;). + #[arg(long, global = true, value_delimiter = ';')] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub layers: Option>, + + /// Type of server + #[arg(long, global = true)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub server: Option, + + /// Version string for the server, e.g. 1.19.4-545 + #[arg(long, global = true)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub server_version: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Run the server + Run(RunCli), + /// Interact with the backup system without starting a server + Backup(BackupArgs), + /// Interact with the PaperMC API and download new JARs + #[command(name = "papermc")] + PaperMC(papermc::Cli), +} + +impl Cli { + pub fn run(&self) -> crate::Result<()> { + let config = self.config(&self.args)?; + + match &self.command { + Commands::Run(args) => args.run(self, &config), + Commands::Backup(args) => Ok(args.run(&config)?), + Commands::PaperMC(cli) => cli.run(), + } + } + + pub fn config(&self, args: &U) -> crate::Result + where + T: Default + Serialize + for<'de> Deserialize<'de>, + U: Serialize, + { + let toml_file = self + .config_file + .clone() + .unwrap_or(PathBuf::from(Env::var_or("ALEX_CONFIG_FILE", ""))); + + let mut figment = Figment::new() + .merge(Serialized::defaults(T::default())) + .merge(Toml::file(toml_file)) + .merge(Env::prefixed("ALEX_").ignore(&["ALEX_LAYERS"])); + + // Layers need to be parsed separately, as the env var format is different than the one + // serde expects + if let Some(layers_env) = Env::var("ALEX_LAYERS") { + let res = layers_env + .split(';') + .map(ManagerConfig::from_str) + .collect::>(); + + if res.iter().any(|e| e.is_err()) { + return Err(crate::other("Invalid layer configuration").into()); + } + + let layers: Vec<_> = res.iter().flatten().collect(); + figment = figment.merge(Serialized::default("layers", layers)); + } + + Ok(figment.merge(Serialized::defaults(args)).extract()?) + } +} diff --git a/alex/src/cli/papermc.rs b/alex/src/cli/papermc.rs new file mode 100644 index 0000000..66fc724 --- /dev/null +++ b/alex/src/cli/papermc.rs @@ -0,0 +1,213 @@ +use std::path::{Path, PathBuf}; + +use chrono::Local; +use clap::{Args, Subcommand}; + +#[derive(Args)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Show information or a specific version or build + Show(ShowArgs), + /// List the available versions, or builds for a specific version + List(ListArgs), + /// Download the jar for a specific build + Download(DownloadBuildArgs), +} + +#[derive(Args)] +pub struct ShowArgs { + /// Version to show information for + version: String, + + /// Build within version to show information for + build: Option, +} + +#[derive(Args)] +pub struct ListArgs { + /// If provided, list the available builds for this version + version: Option, +} + +#[derive(Args)] +pub struct DownloadBuildArgs { + /// Version of build to download + version: String, + /// Build number for build to download + build: String, + + /// Path to store the new JAR file in; stores JAR in the local directory if not specified + #[arg(short, long, value_name = "OUT_PATH")] + out: Option, +} + +impl Cli { + pub fn run(&self) -> crate::Result<()> { + match &self.command { + Commands::Show(ShowArgs { + version, + build: None, + }) => show_version(&version), + Commands::List(ListArgs { version: None }) => list_versions(), + Commands::List(ListArgs { + version: Some(version), + }) => list_builds(version), + Commands::Show(ShowArgs { + version, + build: Some(build), + }) => show_build(&version, &build), + Commands::Download(args) => { + download_build(&args.version, &args.build, args.out.as_deref()) + } + } + + Ok(()) + } +} + +fn show_version(version_str: &str) { + let client = papermc_api::Client::new(); + + let version = match client.project("paper").version(version_str).info() { + Ok(version) => version, + Err(err) => { + println!("failed to query API: {err}"); + return; + } + }; + + println!("id : {}", version.id); + println!("status : {}", version.support_status); + println!("builds : {}", version.builds.len()); + println!("Min. Java : {}", version.java.minimum_version); + println!("Java flags: {}", version.java.recommended_flags.join(" ")) +} + +fn show_build(version_str: &str, build_str: &str) { + let client = papermc_api::Client::new(); + + let build = match client + .project("paper") + .version(version_str) + .build(build_str) + { + Ok(build) => build, + Err(err) => { + println!("failed to query API: {err}"); + return; + } + }; + + println!("id : {}", build.id); + println!("time : {}", build.time.with_timezone(&Local)); + println!("channel: {}", build.channel); + println!("commits:"); + + for commit in build.commits { + println!("- SHA : {}", commit.sha); + println!(" time : {}", commit.time.with_timezone(&Local)); + println!(" message: {}", commit.message); + } + + println!("downloads:"); + + for (name, download) in build.downloads.iter() { + println!(" {name}:"); + println!(" name: {}", download.name); + println!(" size: {}", download.size); + println!(" URL : {}", download.url); + println!(" checksums:"); + + for (name, value) in download.checksums.iter() { + println!(" - {}: {}", name, value); + } + } +} + +fn list_versions() { + let client = papermc_api::Client::new(); + + match client.project("paper").versions() { + Ok(versions) => { + for version in versions { + println!("{} ({})", version.id, version.support_status); + } + } + Err(err) => { + println!("failed to query API: {err}"); + } + } +} + +fn list_builds(version: &str) { + let client = papermc_api::Client::new(); + + match client.project("paper").version(version).builds() { + Ok(builds) => { + for build in builds { + println!("- id : {}", build.id); + println!(" time : {}", build.time.with_timezone(&Local)); + println!(" channel: {}", build.channel); + } + } + Err(err) => { + println!("failed to query API: {err}"); + } + } +} + +fn download_build(version: &str, build: &str, out_path: Option<&Path>) { + let client = papermc_api::Client::new(); + let build = match client.project("paper").version(version).build(build) { + Ok(build) => build, + Err(err) => { + println!("failed to query API: {err}"); + return; + } + }; + + let filename = format!("paper-{}-{}.jar", version, build.id); + let dest_path = match out_path { + Some(path) if path.is_dir() => path.join(filename), + Some(path) => path.to_path_buf(), + None => PathBuf::from(filename), + }; + + let download_url = match build + .downloads + .get("server:default") + .or(build.downloads.values().next()) + { + Some(download) => &download.url, + None => { + println!("no download URLs found for build."); + return; + } + }; + + let mut f = match std::fs::File::create(dest_path) { + Ok(f) => f, + Err(err) => { + println!("failed to create destination file: {err}"); + return; + } + }; + + let mut res = match ureq::get(download_url).call() { + Ok(res) => res, + Err(err) => { + println!("failed to download file: {err}"); + return; + } + }; + + let mut reader = res.body_mut().as_reader(); + if let Err(err) = std::io::copy(&mut reader, &mut f) { + println!("failed to download file: {err}"); + } +} diff --git a/alex/src/cli/run/config.rs b/alex/src/cli/run/config.rs new file mode 100644 index 0000000..5783451 --- /dev/null +++ b/alex/src/cli/run/config.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Config { + pub jar: PathBuf, + pub java: String, + pub xms: u64, + pub xmx: u64, +} + +impl Default for Config { + fn default() -> Self { + Self { + jar: PathBuf::from("server.jar"), + java: String::from("java"), + xms: 1024, + xmx: 2048, + } + } +} diff --git a/alex/src/cli/run/mod.rs b/alex/src/cli/run/mod.rs new file mode 100644 index 0000000..37ddd1b --- /dev/null +++ b/alex/src/cli/run/mod.rs @@ -0,0 +1,103 @@ +mod config; + +use std::{path::PathBuf, sync::Arc}; + +use clap::Args; +use serde::{Deserialize, Serialize}; + +use crate::{server, signals, stdin}; +use config::Config; + +#[derive(Args)] +pub struct RunCli { + #[command(flatten)] + pub args: RunArgs, + + /// Don't actually run the server, but simply output the server configuration that would have + /// been ran + #[arg(short, long, default_value_t = false)] + pub dry: bool, +} + +#[derive(Args, Serialize, Deserialize, Clone)] +pub struct RunArgs { + /// Server jar to execute + #[arg(long, value_name = "JAR_PATH")] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub jar: Option, + + /// Java command to run the server jar with + #[arg(long, value_name = "JAVA_CMD")] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub java: Option, + + /// XMS value in megabytes for the server instance + #[arg(long)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub xms: Option, + + /// XMX value in megabytes for the server instance + #[arg(long)] + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub xmx: Option, +} + +fn backups_thread(server: Arc) { + loop { + let next_scheduled_time = { + server + .backups + .read() + .unwrap() + .next_scheduled_time() + .unwrap() + }; + + let now = chrono::offset::Utc::now(); + if next_scheduled_time > now { + std::thread::sleep((next_scheduled_time - now).to_std().unwrap()); + } + + // We explicitely ignore the error here, as we don't want the thread to fail + let _ = server.backup(); + } +} + +impl RunCli { + pub fn run(&self, cli: &super::Cli, global: &super::Config) -> crate::Result<()> { + let config: Config = cli.config(&self.args)?; + + let (_, mut signals) = signals::install_signal_handlers()?; + + let mut cmd = server::ServerCommand::new(global.server, &global.server_version) + .java(&config.java) + .jar(config.jar.clone()) + .config(global.config.clone()) + .world(global.world.clone()) + .backup(global.backup.clone()) + .managers(global.layers.clone()) + .xms(config.xms) + .xmx(config.xmx); + cmd.canonicalize()?; + + if self.dry { + print!("{}", cmd); + + return Ok(()); + } + + let counter = Arc::new(cmd.spawn()?); + + if !global.layers.is_empty() { + let clone = Arc::clone(&counter); + std::thread::spawn(move || backups_thread(clone)); + } + + // Spawn thread that handles the main stdin loop + let clone = Arc::clone(&counter); + std::thread::spawn(move || stdin::handle_stdin(clone)); + + // Signal handler loop exits the process when necessary + Ok(signals::handle_signals(&mut signals, counter)?) + } +} diff --git a/alex/src/error.rs b/alex/src/error.rs new file mode 100644 index 0000000..b46a49e --- /dev/null +++ b/alex/src/error.rs @@ -0,0 +1,34 @@ +use std::{fmt, io}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + IO(io::Error), + Figment(figment::Error), + Other(Box), +} + +impl fmt::Display for Error { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::IO(err) => write!(fmt, "{}", err), + Error::Figment(err) => write!(fmt, "{}", err), + Error::Other(err) => write!(fmt, "{}", err), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: io::Error) -> Self { + Error::IO(err) + } +} + +impl From for Error { + fn from(err: figment::Error) -> Self { + Error::Figment(err) + } +} diff --git a/alex/src/main.rs b/alex/src/main.rs new file mode 100644 index 0000000..f4bf25d --- /dev/null +++ b/alex/src/main.rs @@ -0,0 +1,42 @@ +mod cli; +mod error; +mod server; +mod signals; +mod stdin; + +use std::io; + +use clap::Parser; + +use crate::cli::Cli; +pub use error::{Error, Result}; + +pub fn other(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::Other, msg) +} + +// fn commands_backup(cli: &Cli, args: &BackupArgs) -> io::Result<()> { +// let metadata = server::Metadata { +// server_type: cli.server, +// server_version: cli.server_version.clone(), +// }; +// let dirs = vec![ +// (PathBuf::from("config"), cli.config.clone()), +// (PathBuf::from("worlds"), cli.world.clone()), +// ]; +// let mut meta = MetaManager::new(cli.backup.clone(), dirs, metadata); +// meta.add_all(&cli.layers)?; + +// match &args.command { +// BackupCommands::List => () +// } + +// // manager.create_backup()?; +// // manager.remove_old_backups() +// } + +fn main() -> crate::Result<()> { + let cli = Cli::parse(); + + cli.run() +} diff --git a/alex/src/server/command.rs b/alex/src/server/command.rs new file mode 100644 index 0000000..0c94732 --- /dev/null +++ b/alex/src/server/command.rs @@ -0,0 +1,210 @@ +use crate::server::{Metadata, ServerProcess, ServerType}; +use backup::ManagerConfig; +use backup::MetaManager; +use std::fmt; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub struct ServerCommand { + type_: ServerType, + version: String, + java: String, + jar: PathBuf, + config_dir: PathBuf, + world_dir: PathBuf, + backup_dir: PathBuf, + xms: u64, + xmx: u64, + managers: Vec, +} + +impl ServerCommand { + pub fn new(type_: ServerType, version: &str) -> Self { + ServerCommand { + type_, + version: String::from(version), + java: String::from("java"), + jar: PathBuf::from("server.jar"), + config_dir: PathBuf::from("config"), + world_dir: PathBuf::from("worlds"), + backup_dir: PathBuf::from("backups"), + xms: 1024, + xmx: 2048, + managers: Vec::new(), + } + } + + pub fn java(mut self, java: &str) -> Self { + self.java = String::from(java); + + self + } + + pub fn jar>(mut self, path: T) -> Self { + self.jar = PathBuf::from(path.as_ref()); + self + } + + pub fn config>(mut self, path: T) -> Self { + self.config_dir = PathBuf::from(path.as_ref()); + self + } + + pub fn world>(mut self, path: T) -> Self { + self.world_dir = PathBuf::from(path.as_ref()); + self + } + + pub fn backup>(mut self, path: T) -> Self { + self.backup_dir = PathBuf::from(path.as_ref()); + + self + } + + pub fn xms(mut self, v: u64) -> Self { + self.xms = v; + self + } + + pub fn xmx(mut self, v: u64) -> Self { + self.xmx = v; + self + } + + pub fn managers(mut self, configs: Vec) -> Self { + self.managers = configs; + + self + } + + fn accept_eula(&self) -> std::io::Result<()> { + let mut eula_path = self.config_dir.clone(); + eula_path.push("eula.txt"); + let mut eula_file = File::create(eula_path)?; + eula_file.write_all(b"eula=true")?; + + Ok(()) + } + + /// Canonicalize all paths to absolute paths. Without this command, all paths will be + /// interpreted relatively from the config directory. + pub fn canonicalize(&mut self) -> std::io::Result<()> { + // To avoid any issues, we use absolute paths for everything when spawning the process + self.jar = self.jar.canonicalize()?; + self.config_dir = self.config_dir.canonicalize()?; + self.world_dir = self.world_dir.canonicalize()?; + self.backup_dir = self.backup_dir.canonicalize()?; + + Ok(()) + } + + fn create_cmd(&self) -> std::process::Command { + let mut cmd = Command::new(&self.java); + + // Apply JVM optimisation flags + // https://aikar.co/2018/07/02/tuning-the-jvm-g1gc-garbage-collector-flags-for-minecraft/ + cmd.arg(format!("-Xms{}M", self.xms)) + .arg(format!("-Xmx{}M", self.xmx)) + .args([ + "-XX:+UseG1GC", + "-XX:+ParallelRefProcEnabled", + "-XX:MaxGCPauseMillis=200", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+DisableExplicitGC", + "-XX:+AlwaysPreTouch", + ]); + + if self.xms > 12 * 1024 { + cmd.args([ + "-XX:G1NewSizePercent=40", + "-XX:G1MaxNewSizePercent=50", + "-XX:G1HeapRegionSize=16M", + "-XX:G1ReservePercent=15", + ]); + } else { + cmd.args([ + "-XX:G1NewSizePercent=30", + "-XX:G1MaxNewSizePercent=40", + "-XX:G1HeapRegionSize=8M", + "-XX:G1ReservePercent=20", + ]); + } + + cmd.args(["-XX:G1HeapWastePercent=5", "-XX:G1MixedGCCountTarget=4"]); + + if self.xms > 12 * 1024 { + cmd.args(["-XX:InitiatingHeapOccupancyPercent=20"]); + } else { + cmd.args(["-XX:InitiatingHeapOccupancyPercent=15"]); + } + + cmd.args([ + "-XX:G1MixedGCLiveThresholdPercent=90", + "-XX:G1RSetUpdatingPauseTimePercent=5", + "-XX:SurvivorRatio=32", + "-XX:+PerfDisableSharedMem", + "-XX:MaxTenuringThreshold=1", + "-Dusing.aikars.flags=https://mcflags.emc.gs", + "-Daikars.new.flags=true", + ]); + + cmd.current_dir(&self.config_dir) + .arg("-jar") + .arg(&self.jar) + .arg("--universe") + .arg(&self.world_dir) + .arg("--nogui") + .stdin(Stdio::piped()); + + cmd + } + + pub fn spawn(&mut self) -> std::io::Result { + let metadata = Metadata { + server_type: self.type_, + server_version: self.version.clone(), + }; + + let dirs = vec![ + (PathBuf::from("config"), self.config_dir.clone()), + (PathBuf::from("worlds"), self.world_dir.clone()), + ]; + + let mut meta = MetaManager::new(self.backup_dir.clone(), dirs, metadata); + meta.add_all(&self.managers)?; + + let mut cmd = self.create_cmd(); + self.accept_eula()?; + let child = cmd.spawn()?; + + Ok(ServerProcess::new(meta, child)) + } +} + +impl fmt::Display for ServerCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let cmd = self.create_cmd(); + + writeln!(f, "Command: {}", self.java)?; + writeln!(f, "Working dir: {}", self.config_dir.as_path().display())?; + + // Print command env vars + writeln!(f, "Environment:")?; + + for (key, val) in cmd.get_envs().filter(|(_, v)| v.is_some()) { + let val = val.unwrap(); + writeln!(f, " {}={}", key.to_string_lossy(), val.to_string_lossy())?; + } + + // Print command arguments + writeln!(f, "Arguments:")?; + + for arg in cmd.get_args() { + writeln!(f, " {}", arg.to_string_lossy())?; + } + + Ok(()) + } +} diff --git a/alex/src/server/mod.rs b/alex/src/server/mod.rs new file mode 100644 index 0000000..2939af7 --- /dev/null +++ b/alex/src/server/mod.rs @@ -0,0 +1,37 @@ +mod command; +mod process; + +pub use command::ServerCommand; +pub use process::ServerProcess; + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ServerType { + Unknown, + Paper, + Forge, + Vanilla, +} + +impl fmt::Display for ServerType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ServerType::Unknown => "Unknown", + ServerType::Paper => "PaperMC", + ServerType::Forge => "Forge", + ServerType::Vanilla => "Vanilla", + }; + + write!(f, "{}", s) + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Metadata { + pub server_type: ServerType, + pub server_version: String, +} diff --git a/alex/src/server/process.rs b/alex/src/server/process.rs new file mode 100644 index 0000000..ca34a0c --- /dev/null +++ b/alex/src/server/process.rs @@ -0,0 +1,95 @@ +use std::{io::Write, process::Child, sync::RwLock}; + +use crate::server::Metadata; +use backup::MetaManager; + +pub struct ServerProcess { + child: RwLock, + pub backups: RwLock>, +} + +impl ServerProcess { + pub fn new(manager: MetaManager, child: Child) -> ServerProcess { + ServerProcess { + child: RwLock::new(child), + backups: RwLock::new(manager), + } + } + + pub fn send_command(&self, cmd: &str) -> std::io::Result<()> { + match cmd.trim() { + "stop" | "exit" => self.stop()?, + "backup" => self.backup()?, + s => self.custom(s)?, + } + + Ok(()) + } + + fn custom(&self, cmd: &str) -> std::io::Result<()> { + let child = self.child.write().unwrap(); + let mut stdin = child.stdin.as_ref().unwrap(); + stdin.write_all(format!("{}\n", cmd.trim()).as_bytes())?; + stdin.flush()?; + + Ok(()) + } + + pub fn stop(&self) -> std::io::Result<()> { + self.custom("stop")?; + + self.child.write().unwrap().wait()?; + + Ok(()) + } + + pub fn kill(&self) -> std::io::Result<()> { + self.child.write().unwrap().kill() + } + + /// Perform a backup by disabling the server's save feature and flushing its data, before + /// creating an archive file. + pub fn backup(&self) -> std::io::Result<()> { + // We explicitely lock this entire function to prevent parallel backups + let mut backups = self.backups.write().unwrap(); + + let layer_name = String::from(backups.next_scheduled_layer().unwrap()); + self.custom(&format!("say starting backup for layer '{}'", layer_name))?; + + // Make sure the server isn't modifying the files during the backup + self.custom("save-off")?; + self.custom("save-all")?; + + // TODO implement a better mechanism + // We wait some time to (hopefully) ensure the save-all call has completed + std::thread::sleep(std::time::Duration::from_secs(10)); + + let start_time = chrono::offset::Utc::now(); + let res = backups.perform_backup_cycle(); + + // The server's save feature needs to be enabled again even if the archive failed to create + self.custom("save-on")?; + self.custom("save-all")?; + + let duration = chrono::offset::Utc::now() - start_time; + let duration_str = format!( + "{}m{}s", + duration.num_seconds() / 60, + duration.num_seconds() % 60 + ); + + if res.is_ok() { + self.custom(&format!( + "say backup created for layer '{}' in {}", + layer_name, duration_str + ))?; + } else { + self.custom(&format!( + "an error occured after {} while creating backup for layer '{}'", + duration_str, layer_name + ))?; + } + + res + } +} diff --git a/alex/src/signals.rs b/alex/src/signals.rs new file mode 100644 index 0000000..87e5c73 --- /dev/null +++ b/alex/src/signals.rs @@ -0,0 +1,69 @@ +use std::{ + io, + sync::{atomic::AtomicBool, Arc}, +}; + +use signal_hook::{ + consts::TERM_SIGNALS, + flag, + iterator::{Signals, SignalsInfo}, +}; + +use crate::server; + +/// Install the required signal handlers for terminating signals. +pub fn install_signal_handlers() -> io::Result<(Arc, SignalsInfo)> { + let term = Arc::new(AtomicBool::new(false)); + + // For each terminating signal, we register both a shutdown handler and a handler that sets an + // atomic bool. With this, the process will get killed immediately once it receives a second + // termination signal (e.g. a double ctrl-c). + // https://docs.rs/signal-hook/0.3.15/signal_hook/#a-complex-signal-handling-with-a-background-thread + for sig in TERM_SIGNALS { + // When terminated by a second term signal, exit with exit code 1. + // This will do nothing the first time (because term_now is false). + flag::register_conditional_shutdown(*sig, 1, Arc::clone(&term))?; + // But this will "arm" the above for the second time, by setting it to true. + // The order of registering these is important, if you put this one first, it will + // first arm and then terminate ‒ all in the first round. + flag::register(*sig, Arc::clone(&term))?; + } + + let signals = TERM_SIGNALS; + + Ok((term, Signals::new(signals)?)) +} + +/// Loop that handles terminating signals as they come in. +pub fn handle_signals( + signals: &mut SignalsInfo, + server: Arc, +) -> io::Result<()> { + let mut force = false; + + // We only register terminating signals, so we don't need to differentiate between what kind of + // signal came in + for _ in signals { + // If term is already true, this is the second signal, meaning we kill the process + // immediately. + // This will currently not work, as the initial stop command will block the kill from + // happening. + if force { + return server.kill(); + } + // The stop command runs in a separate thread to avoid blocking the signal handling loop. + // After stopping the server, the thread terminates the process. + else { + let clone = Arc::clone(&server); + + std::thread::spawn(move || { + let _ = clone.stop(); + std::process::exit(0); + }); + } + + force = true; + } + + Ok(()) +} diff --git a/alex/src/stdin.rs b/alex/src/stdin.rs new file mode 100644 index 0000000..8317671 --- /dev/null +++ b/alex/src/stdin.rs @@ -0,0 +1,24 @@ +use std::{io, sync::Arc}; + +use crate::server; + +pub fn handle_stdin(server: Arc) { + let stdin = io::stdin(); + let input = &mut String::new(); + + loop { + input.clear(); + + if stdin.read_line(input).is_err() { + continue; + }; + + if let Err(e) = server.send_command(input) { + println!("{}", e); + }; + + if input.trim() == "stop" { + std::process::exit(0); + } + } +} diff --git a/backup/Cargo.lock b/backup/Cargo.lock new file mode 100644 index 0000000..a9afc37 --- /dev/null +++ b/backup/Cargo.lock @@ -0,0 +1,547 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backup" +version = "0.4.1" +dependencies = [ + "chrono", + "flate2", + "serde", + "serde_json", + "tar", +] + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix", +] diff --git a/backup/Cargo.toml b/backup/Cargo.toml new file mode 100644 index 0000000..80e5804 --- /dev/null +++ b/backup/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "backup" +version.workspace = true +edition.workspace = true + +[dependencies] +chrono.workspace = true +serde.workspace = true + +# Used for creating tarballs for backups +tar = "0.4.38" +# Used to compress said tarballs using gzip +flate2 = "1.1.1" +serde_json = "1.0.96" diff --git a/backup/Justfile b/backup/Justfile new file mode 100644 index 0000000..cc02572 --- /dev/null +++ b/backup/Justfile @@ -0,0 +1,16 @@ +build: + cargo build --frozen +alias b := build + +test: + cargo test --frozen +alias t := test + +check: + cargo fmt --check + cargo clippy \ + --frozen \ + -- \ + --no-deps \ + --deny 'clippy::all' +alias c := check diff --git a/backup/src/delta.rs b/backup/src/delta.rs new file mode 100644 index 0000000..95ec391 --- /dev/null +++ b/backup/src/delta.rs @@ -0,0 +1,295 @@ +use std::{borrow::Borrow, fmt, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::State; + +/// Represents the changes relative to the previous backup +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +pub struct Delta { + /// What files were added/modified in each part of the tarball. + pub added: State, + /// What files were removed in this backup, in comparison to the previous backup. For full + /// backups, this will always be empty, as they do not consider previous backups. + /// The map stores a separate list for each top-level directory, as the contents of these + /// directories can come for different source directories. + pub removed: State, +} + +impl Delta { + /// Returns whether the delta is empty by checking whether both its added and removed state + /// return true for their `is_empty`. + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() + } + + /// Calculate the union of this delta with another delta. + /// + /// The union of two deltas is a delta that produces the same state as if you were to apply + /// both deltas in-order. Note that this operation is not commutative. + pub fn union(&self, delta: &Self) -> Self { + let mut out = self.clone(); + + for (dir, added) in delta.added.iter() { + // Files that were removed in the current state, but added in the new state, are no + // longer removed + if let Some(orig_removed) = out.removed.get_mut(dir) { + orig_removed.retain(|k| !added.contains(k)); + } + + // Newly added files are added to the state as well + if let Some(orig_added) = out.added.get_mut(dir) { + orig_added.extend(added.iter().cloned()); + } else { + out.added.insert(dir.clone(), added.clone()); + } + } + + for (dir, removed) in delta.removed.iter() { + // Files that were originally added, but now deleted are removed from the added list + if let Some(orig_added) = out.added.get_mut(dir) { + orig_added.retain(|k| !removed.contains(k)); + } + + // Newly removed files are added to the state as well + if let Some(orig_removed) = out.removed.get_mut(dir) { + orig_removed.extend(removed.iter().cloned()); + } else { + out.removed.insert(dir.clone(), removed.clone()); + } + } + + out + } + + // Calculate the difference between this delta and the other delta. + // + // The difference simply means removing all adds and removes that are also performed in the + // other delta. + pub fn difference(&self, other: &Self) -> Self { + let mut out = self.clone(); + + for (dir, added) in out.added.iter_mut() { + // If files are added in the other delta, we don't add them in this delta + if let Some(other_added) = other.added.get(dir) { + added.retain(|k| !other_added.contains(k)); + }; + } + + for (dir, removed) in out.removed.iter_mut() { + // If files are removed in the other delta, we don't remove them in this delta either + if let Some(other_removed) = other.removed.get(dir) { + removed.retain(|k| !other_removed.contains(k)); + } + } + + out + } + + // Calculate the strict difference between this delta and the other delta. + // + // The strict difference is a difference where all operations that would be overwritten by the + // other delta are also removed (a.k.a. adding a file after removing it, or vice versa) + pub fn strict_difference(&self, other: &Self) -> Self { + let mut out = self.difference(other); + + for (dir, added) in out.added.iter_mut() { + // Remove additions that are removed in the other delta + if let Some(other_removed) = other.removed.get(dir) { + added.retain(|k| !other_removed.contains(k)); + } + } + + for (dir, removed) in out.removed.iter_mut() { + // Remove removals that are re-added in the other delta + if let Some(other_added) = other.added.get(dir) { + removed.retain(|k| !other_added.contains(k)); + } + } + + out + } + + /// Given a chain of deltas, calculate the "contribution" for each state. + /// + /// For each delta, its contribution is the part of its added and removed files that isn't + /// overwritten by any of its following deltas. + pub fn contributions(deltas: I) -> Vec + where + I: IntoIterator, + I::IntoIter: DoubleEndedIterator, + I::Item: Borrow, + { + let mut contributions: Vec = Vec::new(); + + let mut deltas = deltas.into_iter().rev(); + + if let Some(first_delta) = deltas.next() { + // From last to first, we calculate the strict difference of the delta with the union of all its + // following deltas. The list of added files of this difference is the contribution for + // that delta. + contributions.push(first_delta.borrow().added.clone()); + let mut union_future = first_delta.borrow().clone(); + + for delta in deltas { + contributions.push(delta.borrow().strict_difference(&union_future).added); + union_future = union_future.union(delta.borrow()); + } + } + + contributions.reverse(); + contributions + } + + /// Append the given files to the directory's list of added files + pub fn append_added(&mut self, dir: impl Into, files: I) + where + I: IntoIterator, + I::Item: Into, + { + self.added.append_dir(dir, files); + } + + /// Wrapper around the `append_added` method for a builder-style construction of delta's + pub fn with_added(mut self, dir: impl Into, files: I) -> Self + where + I: IntoIterator, + I::Item: Into, + { + self.append_added(dir, files); + self + } + + /// Append the given files to the directory's list of removed files + pub fn append_removed(&mut self, dir: impl Into, files: I) + where + I: IntoIterator, + I::Item: Into, + { + self.removed.append_dir(dir, files); + } + + /// Wrapper around the `append_removed` method for a builder-style construction of delta's + pub fn with_removed(mut self, dir: impl Into, files: I) -> Self + where + I: IntoIterator, + I::Item: Into, + { + self.append_removed(dir, files); + self + } +} + +impl fmt::Display for Delta { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let added_count: usize = self.added.values().map(|s| s.len()).sum(); + let removed_count: usize = self.removed.values().map(|s| s.len()).sum(); + + write!(f, "+{}-{}", added_count, removed_count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_union_disjunct_dirs() { + let a = Delta::default() + .with_added("dir_added_1", ["file1", "file2"]) + .with_removed("dir_removed_1", ["file1", "file2"]); + let b = Delta::default() + .with_added("dir_added_3", ["file1", "file2"]) + .with_removed("dir_removed_3", ["file1", "file2"]); + + let expected = Delta::default() + .with_added("dir_added_1", ["file1", "file2"]) + .with_added("dir_added_3", ["file1", "file2"]) + .with_removed("dir_removed_1", ["file1", "file2"]) + .with_removed("dir_removed_3", ["file1", "file2"]); + + assert_eq!(expected, a.union(&b)); + assert_eq!(expected, b.union(&a)); + } + + #[test] + fn test_union_disjunct_files() { + let a = Delta::default() + .with_added("dir_added_1", ["file1", "file2"]) + .with_removed("dir_removed_1", ["file1", "file2"]); + let b = Delta::default() + .with_added("dir_added_1", ["file3", "file4"]) + .with_removed("dir_removed_1", ["file3", "file4"]); + + let expected = Delta::default() + .with_added("dir_added_1", ["file1", "file2", "file3", "file4"]) + .with_removed("dir_removed_1", ["file1", "file2", "file3", "file4"]); + + assert_eq!(expected, a.union(&b)); + assert_eq!(expected, b.union(&a)); + } + + #[test] + fn test_union_full_revert() { + let a = Delta::default().with_added("dir_1", ["file1", "file2"]); + let b = Delta::default().with_removed("dir_1", ["file1", "file2"]); + + let expected = Delta::default().with_removed("dir_1", ["file1", "file2"]); + assert_eq!(expected, a.union(&b)); + + let expected = Delta::default().with_added("dir_1", ["file1", "file2"]); + assert_eq!(expected, b.union(&a)); + } + + #[test] + fn test_difference() { + let a = Delta::default() + .with_added("dir1", ["file1", "file2"]) + .with_removed("dir1", ["file3", "file4"]); + let b = Delta::default() + .with_added("dir1", ["file1"]) + .with_removed("dir1", ["file3"]); + let expected = Delta::default() + .with_added("dir1", ["file2"]) + .with_removed("dir1", ["file4"]); + + assert_eq!(a.difference(&b), expected); + assert_eq!(b.difference(&a), Delta::default()); + } + + #[test] + fn test_strict_difference() { + let a = Delta::default() + .with_added("dir1", ["file1", "file2"]) + .with_removed("dir1", ["file3", "file4"]); + let b = Delta::default() + .with_added("dir1", ["file1", "file4"]) + .with_removed("dir1", ["file3"]); + let expected = Delta::default().with_added("dir1", ["file2"]); + + assert_eq!(a.strict_difference(&b), expected); + assert_eq!(b.strict_difference(&a), Delta::default()); + } + + #[test] + fn test_contributions() { + let deltas = [ + Delta::default().with_added("dir1", ["file4"]), + Delta::default().with_added("dir1", ["file1", "file2"]), + Delta::default() + .with_added("dir1", ["file1"]) + .with_added("dir2", ["file3"]), + Delta::default() + .with_added("dir1", ["file2"]) + .with_removed("dir2", ["file3"]), + ]; + let expected = [ + State::default().with_dir("dir1", ["file4"]), + State::default(), + State::default().with_dir("dir1", ["file1"]), + State::default().with_dir("dir1", ["file2"]), + ]; + + assert_eq!(Delta::contributions(deltas), expected); + } +} diff --git a/backup/src/io_ext.rs b/backup/src/io_ext.rs new file mode 100644 index 0000000..30b919e --- /dev/null +++ b/backup/src/io_ext.rs @@ -0,0 +1,43 @@ +use std::io::{self, Write}; + +/// Wrapper around the Write trait that counts how many bytes have been written in total. +/// Heavily inspired by https://stackoverflow.com/a/42189386 +pub struct CountingWrite { + inner: W, + count: usize, +} + +impl CountingWrite +where + W: Write, +{ + pub fn new(writer: W) -> Self { + Self { + inner: writer, + count: 0, + } + } + + pub fn bytes_written(&self) -> usize { + self.count + } +} + +impl Write for CountingWrite +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> io::Result { + let res = self.inner.write(buf); + + if let Ok(count) = res { + self.count += count; + } + + res + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} diff --git a/backup/src/lib.rs b/backup/src/lib.rs new file mode 100644 index 0000000..6569f83 --- /dev/null +++ b/backup/src/lib.rs @@ -0,0 +1,319 @@ +mod delta; +mod io_ext; +pub mod manager; +mod path; +mod state; + +use std::{ + collections::HashSet, + fmt, + fs::File, + io, + path::{Path, PathBuf}, +}; + +use chrono::Utc; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use serde::{Deserialize, Serialize}; + +use delta::Delta; +pub use manager::Manager; +pub use manager::ManagerConfig; +pub use manager::MetaManager; +use path::PathExt; +pub use state::State; + +const BYTE_SUFFIXES: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + +pub fn other(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::Other, msg) +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub enum BackupType { + Full, + Incremental, +} + +/// Represents a successful backup +#[derive(Serialize, Deserialize, Debug)] +pub struct Backup { + /// When the backup was started (also corresponds to the name) + pub start_time: chrono::DateTime, + /// When the backup finished + pub end_time: chrono::DateTime, + pub size: usize, + /// Type of the backup + pub type_: BackupType, + pub delta: Delta, + /// Additional metadata that can be associated with a given backup + pub metadata: Option, +} + +impl Backup<()> { + pub const FILENAME_FORMAT: &str = "%Y-%m-%d_%H-%M-%S.tar.gz"; + + /// Return the path to a backup file by properly formatting the data. + pub fn path>(backup_dir: P, start_time: chrono::DateTime) -> PathBuf { + let backup_dir = backup_dir.as_ref(); + + let filename = format!("{}", start_time.format(Self::FILENAME_FORMAT)); + backup_dir.join(filename) + } + + /// Extract an archive. + /// + /// # Arguments + /// + /// * `backup_path` - Path to the archive to extract + /// * `dirs` - list of tuples `(path_in_tar, dst_dir)` with `dst_dir` the directory on-disk + /// where the files stored under `path_in_tar` inside the tarball should be extracted to. + pub fn extract_archive>( + archive_path: P, + dirs: &Vec<(PathBuf, PathBuf)>, + ) -> io::Result<()> { + let tar_gz = File::open(archive_path)?; + let enc = GzDecoder::new(tar_gz); + let mut ar = tar::Archive::new(enc); + + // Unpack each file by matching it with one of the destination directories and extracting + // it to the right path + for entry in ar.entries()? { + let mut entry = entry?; + let entry_path_in_tar = entry.path()?.to_path_buf(); + + for (path_in_tar, dst_dir) in dirs { + if entry_path_in_tar.starts_with(path_in_tar) { + let dst_path = + dst_dir.join(entry_path_in_tar.strip_prefix(path_in_tar).unwrap()); + + // Ensure all parent directories are present + std::fs::create_dir_all(dst_path.parent().unwrap())?; + + entry.unpack(dst_path)?; + + break; + } + } + } + + Ok(()) + } +} + +impl Backup { + /// Set the backup's metadata. + pub fn set_metadata(&mut self, metadata: T) { + self.metadata = Some(metadata); + } + + /// Create a new Full backup, populated with the given directories. + /// + /// # Arguments + /// + /// * `backup_dir` - Directory to store archive in + /// * `dirs` - list of tuples `(path_in_tar, src_dir)` with `path_in_tar` the directory name + /// under which `src_dir`'s contents should be stored in the archive + /// + /// # Returns + /// + /// The `Backup` instance describing this new backup. + pub fn create>( + backup_dir: P, + dirs: &Vec<(PathBuf, PathBuf)>, + ) -> io::Result { + let start_time = chrono::offset::Utc::now(); + + let path = Backup::path(backup_dir, start_time); + let tar_gz = io_ext::CountingWrite::new(File::create(path)?); + let enc = GzEncoder::new(tar_gz, Compression::default()); + let mut ar = tar::Builder::new(enc); + + let mut delta = Delta::default(); + + for (dir_in_tar, src_dir) in dirs { + let mut added_files: HashSet = HashSet::new(); + + for entry in src_dir.read_dir_recursive()?.ignored("cache").files() { + let path = entry?.path(); + let stripped = path.strip_prefix(src_dir).unwrap(); + + ar.append_path_with_name(&path, dir_in_tar.join(stripped))?; + added_files.insert(stripped.to_path_buf()); + } + + delta.added.insert(dir_in_tar.to_path_buf(), added_files); + } + + let mut enc = ar.into_inner()?; + + // The docs recommend running try_finish before unwrapping using finish + enc.try_finish()?; + let tar_gz = enc.finish()?; + + Ok(Backup { + type_: BackupType::Full, + start_time, + end_time: chrono::Utc::now(), + size: tar_gz.bytes_written(), + delta, + metadata: None, + }) + } + + /// Create a new Incremental backup from the given state, populated with the given directories. + /// + /// # Arguments + /// + /// * `previous_state` - State the file system was in during the previous backup in the chain + /// * `previous_start_time` - Start time of the previous backup; used to filter files + /// * `backup_dir` - Directory to store archive in + /// * `dirs` - list of tuples `(path_in_tar, src_dir)` with `path_in_tar` the directory name + /// under which `src_dir`'s contents should be stored in the archive + /// + /// # Returns + /// + /// The `Backup` instance describing this new backup. + pub fn create_from>( + previous_state: State, + previous_start_time: chrono::DateTime, + backup_dir: P, + dirs: &Vec<(PathBuf, PathBuf)>, + ) -> io::Result { + let start_time = chrono::offset::Utc::now(); + + let path = Backup::path(backup_dir, start_time); + let tar_gz = io_ext::CountingWrite::new(File::create(path)?); + let enc = GzEncoder::new(tar_gz, Compression::default()); + let mut ar = tar::Builder::new(enc); + + let mut delta = Delta::default(); + + for (dir_in_tar, src_dir) in dirs { + let mut all_files: HashSet = HashSet::new(); + let mut added_files: HashSet = HashSet::new(); + + for entry in src_dir.read_dir_recursive()?.ignored("cache").files() { + let path = entry?.path(); + let stripped = path.strip_prefix(src_dir).unwrap(); + + if !path.not_modified_since(previous_start_time) { + ar.append_path_with_name(&path, dir_in_tar.join(stripped))?; + added_files.insert(stripped.to_path_buf()); + } + + all_files.insert(stripped.to_path_buf()); + } + + delta.added.insert(dir_in_tar.clone(), added_files); + + if let Some(previous_files) = previous_state.get(dir_in_tar) { + delta.removed.insert( + dir_in_tar.to_path_buf(), + previous_files.difference(&all_files).cloned().collect(), + ); + } + } + + let mut enc = ar.into_inner()?; + + // The docs recommend running try_finish before unwrapping using finish + enc.try_finish()?; + let tar_gz = enc.finish()?; + + Ok(Backup { + type_: BackupType::Incremental, + start_time, + end_time: chrono::Utc::now(), + size: tar_gz.bytes_written(), + delta, + metadata: None, + }) + } + + /// Restore the backup by extracting its contents to the respective directories. + /// + /// # Arguments + /// + /// * `backup_dir` - Backup directory where the file is stored + /// * `dirs` - list of tuples `(path_in_tar, dst_dir)` with `dst_dir` the directory on-disk + /// where the files stored under `path_in_tar` inside the tarball should be extracted to. + pub fn restore>( + &self, + backup_dir: P, + dirs: &Vec<(PathBuf, PathBuf)>, + ) -> io::Result<()> { + let backup_path = Backup::path(backup_dir, self.start_time); + Backup::extract_archive(backup_path, dirs)?; + + // Remove any files + for (path_in_tar, dst_dir) in dirs { + if let Some(removed) = self.delta.removed.get(path_in_tar) { + for path in removed { + let dst_path = dst_dir.join(path); + std::fs::remove_file(dst_path)?; + } + } + } + + Ok(()) + } + + pub fn open>(&self, backup_dir: P) -> io::Result>> { + let path = Backup::path(backup_dir, self.start_time); + let tar_gz = File::open(path)?; + let enc = GzDecoder::new(tar_gz); + Ok(tar::Archive::new(enc)) + } + + /// Open this backup's archive and append all its files that are part of the provided state to + /// the archive file. + pub fn append>( + &self, + backup_dir: P, + state: &State, + ar: &mut tar::Builder>, + ) -> io::Result<()> { + let mut own_ar = self.open(backup_dir)?; + + for entry in own_ar.entries()? { + let entry = entry?; + let entry_path_in_tar = entry.path()?.to_path_buf(); + + if state.contains(&entry_path_in_tar) { + let header = entry.header().clone(); + ar.append(&header, entry)?; + } + } + + Ok(()) + } +} + +impl fmt::Display for Backup { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let letter = match self.type_ { + BackupType::Full => 'F', + BackupType::Incremental => 'I', + }; + + // Pretty-print size + // If your backup is a petabyte or larger, this will crash and you need to re-evaluate your + // life choices + let index = self.size.ilog(1024) as usize; + let size = self.size as f64 / (1024.0_f64.powi(index as i32)); + let duration = self.end_time - self.start_time; + + write!( + f, + "{} ({}, {}m{}s, {:.2}{}, {})", + self.start_time.format(Backup::FILENAME_FORMAT), + letter, + duration.num_seconds() / 60, + duration.num_seconds() % 60, + size, + BYTE_SUFFIXES[index], + self.delta + ) + } +} diff --git a/backup/src/manager/config.rs b/backup/src/manager/config.rs new file mode 100644 index 0000000..a07be56 --- /dev/null +++ b/backup/src/manager/config.rs @@ -0,0 +1,46 @@ +use std::{error::Error, fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ManagerConfig { + pub name: String, + pub frequency: u32, + pub chains: u64, + pub chain_len: u64, +} + +#[derive(Debug)] +pub struct ParseManagerConfigErr; + +impl Error for ParseManagerConfigErr {} + +impl fmt::Display for ParseManagerConfigErr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "parse manager config err") + } +} + +impl FromStr for ManagerConfig { + type Err = ParseManagerConfigErr; + + fn from_str(s: &str) -> Result { + let splits: Vec<&str> = s.split(',').collect(); + + if let [name, frequency, chains, chain_len] = splits[..] { + let name: String = name.parse().map_err(|_| ParseManagerConfigErr)?; + let frequency: u32 = frequency.parse().map_err(|_| ParseManagerConfigErr)?; + let chains: u64 = chains.parse().map_err(|_| ParseManagerConfigErr)?; + let chain_len: u64 = chain_len.parse().map_err(|_| ParseManagerConfigErr)?; + + Ok(ManagerConfig { + name, + chains, + chain_len, + frequency, + }) + } else { + Err(ParseManagerConfigErr) + } + } +} diff --git a/backup/src/manager/meta.rs b/backup/src/manager/meta.rs new file mode 100644 index 0000000..a649764 --- /dev/null +++ b/backup/src/manager/meta.rs @@ -0,0 +1,149 @@ +use std::{ + collections::HashMap, + io, + path::{Path, PathBuf}, +}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use super::{Manager, ManagerConfig}; + +/// Manages a collection of backup layers, allowing them to be utilized as a single object. +pub struct MetaManager +where + T: Clone + Serialize + for<'de> Deserialize<'de> + std::fmt::Debug, +{ + backup_dir: PathBuf, + dirs: Vec<(PathBuf, PathBuf)>, + default_metadata: T, + managers: HashMap>, +} + +impl MetaManager +where + T: Clone + Serialize + for<'de> Deserialize<'de> + std::fmt::Debug, +{ + pub fn new>( + backup_dir: P, + dirs: Vec<(PathBuf, PathBuf)>, + default_metadata: T, + ) -> Self { + MetaManager { + backup_dir: backup_dir.into(), + dirs, + default_metadata, + managers: HashMap::new(), + } + } + + /// Add a new manager to track, initializing it first. + pub fn add(&mut self, config: &ManagerConfig) -> io::Result<()> { + // Backup dir itself should exist, but we control its contents, so we can create + // separate directories for each layer + let path = self.backup_dir.join(&config.name); + + // If the directory already exists, that's okay + match std::fs::create_dir(&path) { + Ok(()) => (), + Err(e) => match e.kind() { + io::ErrorKind::AlreadyExists => (), + _ => return Err(e), + }, + }; + + let mut manager = Manager::new( + path, + self.dirs.clone(), + self.default_metadata.clone(), + config.chain_len, + config.chains, + chrono::Duration::minutes(config.frequency.into()), + ); + manager.load()?; + self.managers.insert(config.name.clone(), manager); + + Ok(()) + } + + /// Convenient wrapper for `add`. + pub fn add_all(&mut self, configs: &Vec) -> io::Result<()> { + for config in configs { + self.add(config)?; + } + + Ok(()) + } + + /// Return the name of the next scheduled layer, if one or more managers are present. + pub fn next_scheduled_layer(&self) -> Option<&str> { + self.managers + .iter() + .min_by_key(|(_, m)| m.next_scheduled_time()) + .map(|(k, _)| k.as_str()) + } + + /// Return the earliest scheduled time for the underlying managers. + pub fn next_scheduled_time(&self) -> Option> { + self.managers + .values() + .map(|m| m.next_scheduled_time()) + .min() + } + + /// Perform a backup cycle for the earliest scheduled manager. + pub fn perform_backup_cycle(&mut self) -> io::Result<()> { + if let Some(manager) = self + .managers + .values_mut() + .min_by_key(|m| m.next_scheduled_time()) + { + manager.create_backup()?; + manager.remove_old_backups() + } else { + Ok(()) + } + } + + /// Create a manual backup for a specific layer + pub fn create_backup(&mut self, layer: &str) -> Option> { + if let Some(manager) = self.managers.get_mut(layer) { + let mut res = manager.create_backup(); + + if res.is_ok() { + res = manager.remove_old_backups(); + } + + Some(res) + } else { + None + } + } + + /// Restore a backup for a specific layer + pub fn restore_backup( + &self, + layer: &str, + start_time: chrono::DateTime, + dirs: &Vec<(PathBuf, PathBuf)>, + ) -> Option> { + self.managers + .get(layer) + .map(|manager| manager.restore_backup(start_time, dirs)) + } + + pub fn export_backup>( + &self, + layer: &str, + start_time: chrono::DateTime, + output_path: P, + ) -> Option> { + self.managers + .get(layer) + .map(|manager| manager.export_backup(start_time, output_path)) + } + + pub fn managers(&self) -> &HashMap> { + &self.managers + } +} diff --git a/backup/src/manager/mod.rs b/backup/src/manager/mod.rs new file mode 100644 index 0000000..f13c69f --- /dev/null +++ b/backup/src/manager/mod.rs @@ -0,0 +1,265 @@ +mod config; +mod meta; + +use std::{ + fs::{File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +use chrono::{SubsecRound, Utc}; +use flate2::{write::GzEncoder, Compression}; +use serde::{Deserialize, Serialize}; + +use super::{Backup, BackupType, Delta, State}; +use crate::other; +pub use config::ManagerConfig; +pub use meta::MetaManager; + +/// Manages a single backup layer consisting of one or more chains of backups. +pub struct Manager +where + T: Clone + Serialize + for<'de> Deserialize<'de> + std::fmt::Debug, +{ + backup_dir: PathBuf, + dirs: Vec<(PathBuf, PathBuf)>, + default_metadata: T, + chain_len: u64, + chains_to_keep: u64, + frequency: chrono::Duration, + chains: Vec>>, +} + +impl Manager +where + T: Clone + Serialize + for<'de> Deserialize<'de> + std::fmt::Debug, +{ + const METADATA_FILE: &str = "alex.json"; + + pub fn new>( + backup_dir: P, + dirs: Vec<(PathBuf, PathBuf)>, + metadata: T, + chain_len: u64, + chains_to_keep: u64, + frequency: chrono::Duration, + ) -> Self { + Self { + backup_dir: backup_dir.into(), + dirs, + default_metadata: metadata, + chain_len, + chains_to_keep, + frequency, + chains: Vec::new(), + } + } + + /// Create a new backup, either full or incremental, depending on the state of the current + /// chain. + pub fn create_backup(&mut self) -> io::Result<()> { + // We start a new chain if the current chain is complete, or if there isn't a first chain + // yet + if let Some(current_chain) = self.chains.last() { + let current_chain_len: u64 = current_chain.len().try_into().unwrap(); + + if current_chain_len >= self.chain_len { + self.chains.push(Vec::new()); + } + } else { + self.chains.push(Vec::new()); + } + + let current_chain = self.chains.last_mut().unwrap(); + + let mut backup = if !current_chain.is_empty() { + let previous_backup = current_chain.last().unwrap(); + let previous_state = State::from(current_chain.iter().map(|b| &b.delta)); + + Backup::create_from( + previous_state, + previous_backup.start_time, + &self.backup_dir, + &self.dirs, + )? + } else { + Backup::create(&self.backup_dir, &self.dirs)? + }; + + backup.set_metadata(self.default_metadata.clone()); + + current_chain.push(backup); + + self.save()?; + + Ok(()) + } + + /// Delete all backups associated with outdated chains, and forget those chains. + pub fn remove_old_backups(&mut self) -> io::Result<()> { + let chains_to_store: usize = self.chains_to_keep.try_into().unwrap(); + + if chains_to_store < self.chains.len() { + let mut remove_count: usize = self.chains.len() - chains_to_store; + + // We only count finished chains towards the list of stored chains + let chain_len: usize = self.chain_len.try_into().unwrap(); + if self.chains.last().unwrap().len() < chain_len { + remove_count -= 1; + } + + for chain in self.chains.drain(..remove_count) { + for backup in chain { + let path = Backup::path(&self.backup_dir, backup.start_time); + std::fs::remove_file(path)?; + } + } + + self.save()?; + } + + Ok(()) + } + + /// Write the in-memory state to disk. + /// + /// The state is first written to a temporary file before being (atomically, depending on the + /// file system) renamed to the final path. + pub fn save(&self) -> io::Result<()> { + let dest_path = self.backup_dir.join(Self::METADATA_FILE); + + let dest_ext = dest_path + .extension() + .map(|ext| ext.to_string_lossy().to_string()) + .unwrap_or(String::new()); + let temp_path = dest_path.with_extension(format!("{dest_ext}.temp")); + + let json_file = File::create(&temp_path)?; + serde_json::to_writer(json_file, &self.chains)?; + + // Rename temp file to the destination path after writing was successful + std::fs::rename(temp_path, dest_path)?; + + Ok(()) + } + + /// Overwrite the in-memory state with the on-disk state. + pub fn load(&mut self) -> io::Result<()> { + let json_file = match File::open(self.backup_dir.join(Self::METADATA_FILE)) { + Ok(f) => f, + Err(e) => { + // Don't error out if the file isn't there, it will be created when necessary + if e.kind() == io::ErrorKind::NotFound { + self.chains = Vec::new(); + + return Ok(()); + } else { + return Err(e); + } + } + }; + + self.chains = serde_json::from_reader(json_file)?; + + Ok(()) + } + + /// Calculate the next time a backup should be created. If no backup has been created yet, it + /// will return now. + pub fn next_scheduled_time(&self) -> chrono::DateTime { + self.chains + .last() + .and_then(|last_chain| last_chain.last()) + .map(|last_backup| last_backup.start_time + self.frequency) + .unwrap_or_else(chrono::offset::Utc::now) + } + + /// Search for a chain containing a backup with the specified start time. + /// + /// # Returns + /// + /// A tuple (chain, index) with index being the index of the found backup in the returned + /// chain. + fn find(&self, start_time: chrono::DateTime) -> Option<(&Vec>, usize)> { + for chain in &self.chains { + if let Some(index) = chain + .iter() + .position(|b| b.start_time.trunc_subsecs(0) == start_time) + { + return Some((chain, index)); + } + } + + None + } + + /// Restore the backup with the given start time by restoring its chain up to and including the + /// backup, in order. + pub fn restore_backup( + &self, + start_time: chrono::DateTime, + dirs: &Vec<(PathBuf, PathBuf)>, + ) -> io::Result<()> { + self.find(start_time) + .ok_or_else(|| other("Unknown layer.")) + .and_then(|(chain, index)| { + for backup in chain.iter().take(index + 1) { + backup.restore(&self.backup_dir, dirs)?; + } + + Ok(()) + }) + } + + /// Export the backup with the given start time as a new full archive. + pub fn export_backup>( + &self, + start_time: chrono::DateTime, + output_path: P, + ) -> io::Result<()> { + self.find(start_time) + .ok_or_else(|| other("Unknown layer.")) + .and_then(|(chain, index)| { + match chain[index].type_ { + // A full backup is simply copied to the output path + BackupType::Full => std::fs::copy( + Backup::path(&self.backup_dir, chain[index].start_time), + output_path, + ) + .map(|_| ()), + // Incremental backups are exported one by one according to their contribution + BackupType::Incremental => { + let contributions = + Delta::contributions(chain.iter().take(index + 1).map(|b| &b.delta)); + + let tar_gz = OpenOptions::new() + .write(true) + .create(true) + .open(output_path.as_ref())?; + let enc = GzEncoder::new(tar_gz, Compression::default()); + let mut ar = tar::Builder::new(enc); + + // We only need to consider backups that have a non-empty contribution. + // This allows us to skip reading backups that have been completely + // overwritten by their successors anyways. + for (contribution, backup) in contributions + .iter() + .zip(chain.iter().take(index + 1)) + .filter(|(contribution, _)| !contribution.is_empty()) + { + println!("{}", &backup); + backup.append(&self.backup_dir, contribution, &mut ar)?; + } + + let mut enc = ar.into_inner()?; + enc.try_finish() + } + } + }) + } + + /// Get a reference to the underlying chains + pub fn chains(&self) -> &Vec>> { + &self.chains + } +} diff --git a/backup/src/path.rs b/backup/src/path.rs new file mode 100644 index 0000000..d8c2cec --- /dev/null +++ b/backup/src/path.rs @@ -0,0 +1,149 @@ +use std::{ + collections::HashSet, + ffi::OsString, + fs::{self, DirEntry}, + io, + path::{Path, PathBuf}, +}; + +use chrono::{Local, Utc}; + +pub struct ReadDirRecursive { + ignored: HashSet, + read_dir: fs::ReadDir, + dir_stack: Vec, + files_only: bool, +} + +impl ReadDirRecursive { + /// Start the iterator for a new directory + pub fn start>(path: P) -> io::Result { + let path = path.as_ref(); + let read_dir = path.read_dir()?; + + Ok(ReadDirRecursive { + ignored: HashSet::new(), + read_dir, + dir_stack: Vec::new(), + files_only: false, + }) + } + + pub fn ignored>(mut self, s: S) -> Self { + self.ignored.insert(s.into()); + + self + } + + pub fn files(mut self) -> Self { + self.files_only = true; + + self + } + + /// Tries to populate the `read_dir` field with a new `ReadDir` instance to consume. + fn next_read_dir(&mut self) -> io::Result { + if let Some(path) = self.dir_stack.pop() { + self.read_dir = path.read_dir()?; + + Ok(true) + } else { + Ok(false) + } + } + + /// Convenience method to add a new directory to the stack. + fn push_entry(&mut self, entry: &io::Result) { + if let Ok(entry) = entry { + if entry.path().is_dir() { + self.dir_stack.push(entry.path()); + } + } + } + + /// Determine whether an entry should be returned by the iterator. + fn should_return(&self, entry: &io::Result) -> bool { + if let Ok(entry) = entry { + let mut res = !self.ignored.contains(&entry.file_name()); + + // Please just let me combine these already + if self.files_only { + if let Ok(file_type) = entry.file_type() { + res = res && file_type.is_file(); + } + // We couldn't determine if it's a file, so we don't return it + else { + res = false; + } + } + + res + } else { + true + } + } +} + +impl Iterator for ReadDirRecursive { + type Item = io::Result; + + fn next(&mut self) -> Option { + loop { + // First, we try to consume the current directory's items + while let Some(entry) = self.read_dir.next() { + self.push_entry(&entry); + + if self.should_return(&entry) { + return Some(entry); + } + } + + // If we get an error while setting up a new directory, we return this, otherwise we + // keep trying to consume the directories + match self.next_read_dir() { + Ok(true) => (), + // There's no more directories to traverse, so the iterator is done + Ok(false) => return None, + Err(e) => return Some(Err(e)), + } + } + } +} + +pub trait PathExt { + /// Confirm whether the file has not been modified since the given timestamp. + /// + /// This function will only return true if it can determine with certainty that the file hasn't + /// been modified. + /// + /// # Args + /// + /// * `timestamp` - Timestamp to compare modified time with + /// + /// # Returns + /// + /// True if the file has not been modified for sure, false otherwise. + fn not_modified_since(&self, timestamp: chrono::DateTime) -> bool; + + /// An extension of the `read_dir` command that runs through the entire underlying directory + /// structure using breadth-first search + fn read_dir_recursive(&self) -> io::Result; +} + +impl PathExt for Path { + fn not_modified_since(&self, timestamp: chrono::DateTime) -> bool { + self.metadata() + .and_then(|m| m.modified()) + .map(|last_modified| { + let t: chrono::DateTime = last_modified.into(); + let t = t.with_timezone(&Local); + + t < timestamp + }) + .unwrap_or(false) + } + + fn read_dir_recursive(&self) -> io::Result { + ReadDirRecursive::start(self) + } +} diff --git a/backup/src/state.rs b/backup/src/state.rs new file mode 100644 index 0000000..b94370f --- /dev/null +++ b/backup/src/state.rs @@ -0,0 +1,162 @@ +use std::{ + borrow::Borrow, + collections::{HashMap, HashSet}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::Delta; + +/// Struct that represents a current state for a backup. This struct acts as a smart pointer around +/// a HashMap. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct State(HashMap>); + +impl State { + /// Apply the delta to the current state. + pub fn apply(&mut self, delta: &Delta) { + // First we add new files, then we remove the old ones + for (dir, added) in delta.added.iter() { + if let Some(current) = self.0.get_mut(dir) { + current.extend(added.iter().cloned()); + } else { + self.0.insert(dir.clone(), added.clone()); + } + } + + for (dir, removed) in delta.removed.iter() { + if let Some(current) = self.0.get_mut(dir) { + current.retain(|k| !removed.contains(k)); + } + } + } + + /// Returns whether the provided relative path is part of the given state. + pub fn contains>(&self, path: P) -> bool { + let path = path.as_ref(); + + self.0.iter().any(|(dir, files)| { + path.starts_with(dir) && files.contains(path.strip_prefix(dir).unwrap()) + }) + } + + /// Returns whether the state is empty. + /// + /// Note that this does not necessarily mean that the state does not contain any sets, but + /// rather that any sets that it does contain are also empty. + pub fn is_empty(&self) -> bool { + self.0.values().all(|s| s.is_empty()) + } + + pub fn append_dir(&mut self, dir: impl Into, files: I) + where + I: IntoIterator, + I::Item: Into, + { + let dir = dir.into(); + let files = files.into_iter().map(Into::into); + + if let Some(dir_files) = self.0.get_mut(&dir) { + dir_files.extend(files); + } else { + self.0.insert(dir, files.collect()); + } + } + + pub fn with_dir(mut self, dir: impl Into, files: I) -> Self + where + I: IntoIterator, + I::Item: Into, + { + self.append_dir(dir, files); + self + } +} + +impl PartialEq for State { + fn eq(&self, other: &Self) -> bool { + let self_non_empty = self.0.values().filter(|files| !files.is_empty()).count(); + let other_non_empty = other.0.values().filter(|files| !files.is_empty()).count(); + + if self_non_empty != other_non_empty { + return false; + } + + // If both states have the same number of non-empty directories, then comparing each + // directory of one with the other will only be true if their list of non-empty directories + // is identical. + self.0 + .iter() + .all(|(dir, files)| files.is_empty() || other.0.get(dir).map_or(false, |v| v == files)) + } +} + +impl Eq for State {} + +impl From for State +where + T: IntoIterator, + T::Item: Borrow, +{ + fn from(deltas: T) -> Self { + let mut state = State::default(); + + for delta in deltas { + state.apply(delta.borrow()); + } + + state + } +} + +impl AsRef>> for State { + fn as_ref(&self) -> &HashMap> { + &self.0 + } +} + +impl Deref for State { + type Target = HashMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for State { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eq() { + let a = State::default().with_dir("dir1", ["file1", "file2"]); + let b = State::default().with_dir("dir1", ["file1", "file2"]); + + assert_eq!(a, b); + + let b = b.with_dir("dir2", ["file3"]); + + assert_ne!(a, b); + } + + #[test] + fn test_eq_empty_dirs() { + let a = State::default().with_dir("dir1", ["file1", "file2"]); + let b = State::default() + .with_dir("dir1", ["file1", "file2"]) + .with_dir("dir2", Vec::::new()); + + assert_eq!(a, b); + + let b = b.with_dir("dir2", ["file3"]); + assert_ne!(a, b); + } +} diff --git a/papermc-api/Cargo.toml b/papermc-api/Cargo.toml new file mode 100644 index 0000000..9c8a2a0 --- /dev/null +++ b/papermc-api/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "papermc-api" +version.workspace = true +edition.workspace = true + +[dependencies] +serde.workspace = true +chrono.workspace = true +serde_json = "1.0.149" +ureq = { version = "3.3.0", features = ["json"] } diff --git a/papermc-api/examples/routes.rs b/papermc-api/examples/routes.rs new file mode 100644 index 0000000..5628546 --- /dev/null +++ b/papermc-api/examples/routes.rs @@ -0,0 +1,19 @@ +fn main() { + let client = papermc_api::Client::new(); + let projects = client.projects().unwrap(); + + for project in projects { + println!("project: {:?}", project); + } + + let versions = client.project("paper").versions().unwrap(); + for version in versions { + println!("version: {:?}", version); + } + + let latest = client.project("paper").version("1.21.1").latest().unwrap(); + println!("latest: {:?}", latest); + + let builds = client.project("paper").version("1.21.10").builds().unwrap(); + println!("number of builds: {}", builds.len()); +} diff --git a/papermc-api/src/error.rs b/papermc-api/src/error.rs new file mode 100644 index 0000000..3e9fa21 --- /dev/null +++ b/papermc-api/src/error.rs @@ -0,0 +1,22 @@ +#[derive(Debug)] +pub enum Error { + Ureq(ureq::Error), + BadBody, +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: ureq::Error) -> Self { + Self::Ureq(value) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ureq(err) => err.fmt(f), + Self::BadBody => f.write_str("bad response body"), + } + } +} diff --git a/papermc-api/src/lib.rs b/papermc-api/src/lib.rs new file mode 100644 index 0000000..1de90b5 --- /dev/null +++ b/papermc-api/src/lib.rs @@ -0,0 +1,247 @@ +use serde_json::Value; + +pub use error::Error; + +use crate::models::{Build, BuildCommit, BuildDownload, Java, Project, Version}; + +mod error; +mod models; + +pub struct Client { + agent: ureq::Agent, +} + +pub const BASE_URL: &str = "https://fill.papermc.io/v3"; + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + pub fn new() -> Self { + Self { + agent: ureq::agent(), + } + } + + pub fn projects(&self) -> Result, Error> { + let mut res = self.agent.get(format!("{}/projects", BASE_URL)).call()?; + let body_json: Value = res.body_mut().read_json()?; + + let projects = body_json["projects"].as_array().ok_or(Error::BadBody)?; + projects.iter().map(parse_project_json).collect() + } + + pub fn project<'a>(&'a self, project: &'a str) -> ProjectQuery<'a> { + ProjectQuery { + agent: &self.agent, + project, + } + } +} + +pub struct ProjectQuery<'a> { + agent: &'a ureq::Agent, + project: &'a str, +} + +impl<'a> ProjectQuery<'a> { + pub fn info(&self) -> Result { + let mut res = self + .agent + .get(format!("{}/projects/{}", BASE_URL, self.project)) + .call()?; + let body_json: Value = res.body_mut().read_json()?; + + parse_project_json(&body_json) + } + + pub fn versions(&self) -> Result, Error> { + let mut res = self + .agent + .get(format!("{}/projects/{}/versions", BASE_URL, self.project)) + .call()?; + let body_json: Value = res.body_mut().read_json()?; + + let versions = body_json["versions"].as_array().ok_or(Error::BadBody)?; + versions.iter().map(parse_version_json).collect() + } + + pub fn version(&self, version: &'a str) -> VersionQuery<'a> { + VersionQuery { + agent: self.agent, + project: self.project, + version, + } + } +} + +pub struct VersionQuery<'a> { + agent: &'a ureq::Agent, + project: &'a str, + version: &'a str, +} + +impl<'a> VersionQuery<'a> { + pub fn info(&self) -> Result { + let mut res = self + .agent + .get(format!( + "{}/projects/{}/versions/{}", + BASE_URL, self.project, self.version + )) + .call()?; + let body_json: Value = res.body_mut().read_json()?; + + parse_version_json(&body_json) + } + + pub fn builds(&self) -> Result, Error> { + let mut res = self + .agent + .get(format!( + "{}/projects/{}/versions/{}/builds", + BASE_URL, self.project, self.version + )) + .call()?; + let body_json: Value = res.body_mut().read_json()?; + + body_json + .as_array() + .ok_or(Error::BadBody)? + .iter() + .map(parse_build_json) + .collect() + } + + pub fn build(&self, build: &str) -> Result { + let mut res = self + .agent + .get(format!( + "{}/projects/{}/versions/{}/builds/{}", + BASE_URL, self.project, self.version, build + )) + .call()?; + let body_json: Value = res.body_mut().read_json()?; + + parse_build_json(&body_json) + } + + pub fn latest(&self) -> Result { + self.build("latest") + } +} + +fn parse_project_json(value: &Value) -> Result { + Ok(Project { + id: value["project"]["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or(Error::BadBody)?, + name: value["project"]["name"] + .as_str() + .map(|s| s.to_string()) + .ok_or(Error::BadBody)?, + // Flatten map of versions into one array + versions: value["versions"] + .as_object() + .ok_or(Error::BadBody)? + .iter() + .map(|(_, versions)| versions.as_array().ok_or(Error::BadBody)) + // Collect into error to propagate error of any of the versions + .collect::, _>>()? + .into_iter() + .flatten() + .map(|v| v.as_str().ok_or(Error::BadBody).map(|s| s.to_string())) + .collect::>()?, + }) +} + +fn parse_version_json(value: &Value) -> Result { + Ok(Version { + id: value["version"]["id"] + .as_str() + .map(String::from) + .ok_or(Error::BadBody)?, + support_status: value["version"]["support"]["status"] + .as_str() + .ok_or(Error::BadBody)? + .parse() + .map_err(|_| Error::BadBody)?, + java: Java { + minimum_version: value["version"]["java"]["version"]["minimum"] + .as_u64() + .ok_or(Error::BadBody)?, + recommended_flags: value["version"]["java"]["flags"]["recommended"] + .as_array() + .ok_or(Error::BadBody)? + .iter() + .map(|v| v.as_str().map(String::from).ok_or(Error::BadBody)) + .collect::>()?, + }, + builds: value["builds"] + .as_array() + .ok_or(Error::BadBody)? + .iter() + .map(|v| v.as_u64().ok_or(Error::BadBody)) + .collect::>()?, + }) +} + +fn parse_build_json(value: &Value) -> Result { + Ok(Build { + id: value["id"].as_u64().ok_or(Error::BadBody)?, + time: chrono::DateTime::parse_from_rfc3339(value["time"].as_str().ok_or(Error::BadBody)?) + .map_err(|_| Error::BadBody)? + .into(), + channel: value["channel"] + .as_str() + .ok_or(Error::BadBody)? + .parse() + .map_err(|_| Error::BadBody)?, + commits: value["commits"] + .as_array() + .ok_or(Error::BadBody)? + .iter() + .map(|build| { + Ok(BuildCommit { + sha: build["sha"].as_str().ok_or(Error::BadBody)?.to_string(), + time: chrono::DateTime::parse_from_rfc3339( + build["time"].as_str().ok_or(Error::BadBody)?, + ) + .map_err(|_| Error::BadBody)? + .into(), + message: build["message"].as_str().ok_or(Error::BadBody)?.to_string(), + }) + }) + .collect::>()?, + downloads: value["downloads"] + .as_object() + .ok_or(Error::BadBody)? + .iter() + .map(|(key, build)| { + Ok(( + key.to_string(), + BuildDownload { + name: build["name"].as_str().ok_or(Error::BadBody)?.to_string(), + size: build["size"].as_u64().ok_or(Error::BadBody)?, + url: build["url"].as_str().ok_or(Error::BadBody)?.to_string(), + checksums: build["checksums"] + .as_object() + .ok_or(Error::BadBody)? + .into_iter() + .map(|(key, value)| { + Ok(( + key.to_string(), + value.as_str().ok_or(Error::BadBody)?.to_string(), + )) + }) + .collect::>()?, + }, + )) + }) + .collect::>()?, + }) +} diff --git a/papermc-api/src/models.rs b/papermc-api/src/models.rs new file mode 100644 index 0000000..c28f7e9 --- /dev/null +++ b/papermc-api/src/models.rs @@ -0,0 +1,109 @@ +use std::{collections::HashMap, fmt::Display, str::FromStr}; + +use chrono::{DateTime, Utc}; + +#[derive(Debug)] +pub struct Project { + pub id: String, + pub name: String, + pub versions: Vec, +} + +#[derive(Debug)] +pub enum SupportStatus { + Supported, + Unsupported, +} + +pub struct SupportStatusParseError; + +impl FromStr for SupportStatus { + type Err = SupportStatusParseError; + + fn from_str(s: &str) -> Result { + match s { + "SUPPORTED" => Ok(Self::Supported), + "UNSUPPORTED" => Ok(Self::Unsupported), + _ => Err(SupportStatusParseError), + } + } +} + +impl Display for SupportStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Supported => f.write_str("SUPPORTED"), + Self::Unsupported => f.write_str("UNSUPPORTED"), + } + } +} + +#[derive(Debug)] +pub struct Java { + pub minimum_version: u64, + pub recommended_flags: Vec, +} + +#[derive(Debug)] +pub struct Version { + pub id: String, + pub support_status: SupportStatus, + pub java: Java, + pub builds: Vec, +} + +#[derive(Debug)] +pub struct Build { + pub id: u64, + pub time: DateTime, + pub channel: BuildChannel, + pub commits: Vec, + pub downloads: HashMap, +} + +#[derive(Debug)] +pub enum BuildChannel { + Alpha, + Beta, + Stable, +} + +pub struct BuildChannelParseError; + +impl FromStr for BuildChannel { + type Err = BuildChannelParseError; + + fn from_str(s: &str) -> Result { + match s { + "STABLE" => Ok(Self::Stable), + "BETA" => Ok(Self::Beta), + "ALPHA" => Ok(Self::Alpha), + _ => Err(BuildChannelParseError), + } + } +} + +impl Display for BuildChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Alpha => f.write_str("ALPHA"), + Self::Beta => f.write_str("BETA"), + Self::Stable => f.write_str("STABLE"), + } + } +} + +#[derive(Debug)] +pub struct BuildCommit { + pub sha: String, + pub time: DateTime, + pub message: String, +} + +#[derive(Debug)] +pub struct BuildDownload { + pub name: String, + pub checksums: HashMap, + pub size: u64, + pub url: String, +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 7837887..0000000 --- a/src/main.rs +++ /dev/null @@ -1,123 +0,0 @@ -mod server; - -use clap::Parser; -use server::ServerType; -use std::io; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Type of server - type_: ServerType, - /// Version string for the server, e.g. 1.19.4-545 - #[arg(env = "ALEX_SERVER_VERSION")] - server_version: String, - - /// Server jar to execute - #[arg( - long, - value_name = "JAR_PATH", - default_value = "server.jar", - env = "ALEX_JAR" - )] - jar: PathBuf, - /// Directory where configs are stored, and where the server will run - #[arg( - long, - value_name = "CONFIG_DIR", - default_value = ".", - env = "ALEX_CONFIG_DIR" - )] - config: PathBuf, - /// Directory where world files will be saved - #[arg( - long, - value_name = "WORLD_DIR", - default_value = "../worlds", - env = "ALEX_WORLD_DIR" - )] - world: PathBuf, - /// Directory where backups will be stored - #[arg( - long, - value_name = "BACKUP_DIR", - default_value = "../backups", - env = "ALEX_WORLD_DIR" - )] - backup: PathBuf, - /// Java command to run the server jar with - #[arg(long, value_name = "JAVA_CMD", default_value_t = String::from("java"), env = "ALEX_JAVA")] - java: String, - - /// XMS value in megabytes for the server instance - #[arg(long, default_value_t = 1024, env = "ALEX_XMS")] - xms: u64, - /// XMX value in megabytes for the server instance - #[arg(long, default_value_t = 2048, env = "ALEX_XMX")] - xmx: u64, - - /// How many backups to keep - #[arg(short = 'n', long, default_value_t = 7, env = "ALEX_MAX_BACKUPS")] - max_backups: u64, - /// How frequently to perform a backup, in minutes; 0 to disable. - #[arg(short = 't', long, default_value_t = 0, env = "ALEX_FREQUENCY")] - frequency: u64, -} - -fn backups_thread(counter: Arc>, frequency: u64) { - loop { - std::thread::sleep(std::time::Duration::from_secs(frequency * 60)); - - { - let mut server = counter.lock().unwrap(); - - // We explicitely ignore the error here, as we don't want the thread to fail - let _ = server.backup(); - } - } -} - -fn main() { - let cli = Cli::parse(); - - let cmd = server::ServerCommand::new(cli.type_, &cli.server_version) - .java(&cli.java) - .jar(cli.jar) - .config(cli.config) - .world(cli.world) - .backup(cli.backup) - .xms(cli.xms) - .xmx(cli.xmx) - .max_backups(cli.max_backups); - let counter = Arc::new(Mutex::new(cmd.spawn().expect("Failed to start server."))); - - if cli.frequency > 0 { - let clone = Arc::clone(&counter); - std::thread::spawn(move || backups_thread(clone, cli.frequency)); - } - - let stdin = io::stdin(); - let input = &mut String::new(); - - loop { - input.clear(); - - if stdin.read_line(input).is_err() { - continue; - }; - - { - let mut server = counter.lock().unwrap(); - - if let Err(e) = server.send_command(input) { - println!("{}", e); - }; - } - - if input.trim() == "stop" { - break; - } - } -} diff --git a/src/server/command.rs b/src/server/command.rs deleted file mode 100644 index e774bb5..0000000 --- a/src/server/command.rs +++ /dev/null @@ -1,137 +0,0 @@ -use crate::server::ServerProcess; -use clap::ValueEnum; -use std::fmt; -use std::fs::File; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -pub enum ServerType { - Paper, - Forge, - Vanilla, -} - -impl fmt::Display for ServerType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - ServerType::Paper => "PaperMC", - ServerType::Forge => "Forge", - ServerType::Vanilla => "Vanilla", - }; - - write!(f, "{}", s) - } -} - -pub struct ServerCommand { - type_: ServerType, - version: String, - java: String, - jar: PathBuf, - config_dir: PathBuf, - world_dir: PathBuf, - backup_dir: PathBuf, - xms: u64, - xmx: u64, - max_backups: u64, -} - -impl ServerCommand { - pub fn new(type_: ServerType, version: &str) -> Self { - ServerCommand { - type_, - version: String::from(version), - java: String::from("java"), - jar: PathBuf::from("server.jar"), - config_dir: PathBuf::from("config"), - world_dir: PathBuf::from("worlds"), - backup_dir: PathBuf::from("backups"), - xms: 1024, - xmx: 2048, - max_backups: 7, - } - } - - pub fn java(mut self, java: &str) -> Self { - self.java = String::from(java); - - self - } - - pub fn jar>(mut self, path: T) -> Self { - self.jar = PathBuf::from(path.as_ref()); - self - } - - pub fn config>(mut self, path: T) -> Self { - self.config_dir = PathBuf::from(path.as_ref()); - self - } - - pub fn world>(mut self, path: T) -> Self { - self.world_dir = PathBuf::from(path.as_ref()); - self - } - - pub fn backup>(mut self, path: T) -> Self { - self.backup_dir = PathBuf::from(path.as_ref()); - - self - } - - pub fn xms(mut self, v: u64) -> Self { - self.xms = v; - self - } - - pub fn xmx(mut self, v: u64) -> Self { - self.xmx = v; - self - } - - pub fn max_backups(mut self, v: u64) -> Self { - self.max_backups = v; - self - } - - fn accept_eula(&self) -> std::io::Result<()> { - let mut eula_path = self.config_dir.clone(); - eula_path.push("eula.txt"); - let mut eula_file = File::create(eula_path)?; - eula_file.write_all(b"eula=true")?; - - Ok(()) - } - - pub fn spawn(self) -> std::io::Result { - // To avoid any issues, we use absolute paths for everything when spawning the process - let jar = self.jar.canonicalize()?; - let config_dir = self.config_dir.canonicalize()?; - let world_dir = self.world_dir.canonicalize()?; - let backup_dir = self.backup_dir.canonicalize()?; - - self.accept_eula()?; - - let child = Command::new(&self.java) - .current_dir(&config_dir) - .arg("-jar") - .arg(&jar) - .arg("--universe") - .arg(&world_dir) - .arg("--nogui") - .stdin(Stdio::piped()) - .spawn()?; - - Ok(ServerProcess::new( - self.type_, - self.version, - config_dir, - world_dir, - backup_dir, - self.max_backups, - child, - )) - } -} diff --git a/src/server/mod.rs b/src/server/mod.rs deleted file mode 100644 index e3e3131..0000000 --- a/src/server/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod command; -mod process; - -pub use command::{ServerCommand, ServerType}; -pub use process::ServerProcess; diff --git a/src/server/process.rs b/src/server/process.rs deleted file mode 100644 index 2b6f6fa..0000000 --- a/src/server/process.rs +++ /dev/null @@ -1,159 +0,0 @@ -use crate::server::ServerType; -use flate2::write::GzEncoder; -use flate2::Compression; -use std::io::Write; -use std::path::PathBuf; -use std::process::Child; - -#[link(name = "c")] -extern "C" { - fn geteuid() -> u32; - fn getegid() -> u32; -} - -pub struct ServerProcess { - type_: ServerType, - version: String, - config_dir: PathBuf, - world_dir: PathBuf, - backup_dir: PathBuf, - max_backups: u64, - child: Child, -} - -impl ServerProcess { - pub fn new( - type_: ServerType, - version: String, - config_dir: PathBuf, - world_dir: PathBuf, - backup_dir: PathBuf, - max_backups: u64, - child: Child, - ) -> ServerProcess { - ServerProcess { - type_, - version, - config_dir, - world_dir, - backup_dir, - max_backups, - child, - } - } - - pub fn send_command(&mut self, cmd: &str) -> std::io::Result<()> { - match cmd.trim() { - "stop" | "exit" => self.stop()?, - "backup" => self.backup()?, - s => self.custom(s)?, - } - - Ok(()) - } - - fn custom(&mut self, cmd: &str) -> std::io::Result<()> { - let mut stdin = self.child.stdin.as_ref().unwrap(); - stdin.write_all(format!("{}\n", cmd.trim()).as_bytes())?; - stdin.flush()?; - - Ok(()) - } - - pub fn stop(&mut self) -> std::io::Result<()> { - self.custom("stop")?; - self.child.wait()?; - - Ok(()) - } - - /// Perform a backup by disabling the server's save feature and flushing its data, before - /// creating an archive file. - pub fn backup(&mut self) -> std::io::Result<()> { - self.custom("say backing up server")?; - - // Make sure the server isn't modifying the files during the backup - self.custom("save-off")?; - self.custom("save-all")?; - - // TODO implement a better mechanism - // We wait some time to (hopefully) ensure the save-all call has completed - std::thread::sleep(std::time::Duration::from_secs(10)); - - let res = self.create_backup_archive(); - - if res.is_ok() { - self.remove_old_backups()?; - } - - // The server's save feature needs to be enabled again even if the archive failed to create - self.custom("save-on")?; - - self.custom("say server backed up successfully")?; - - res - } - - /// Create a new compressed backup archive of the server's data. - fn create_backup_archive(&mut self) -> std::io::Result<()> { - // Create a gzip-compressed tarball of the worlds folder - let filename = format!( - "{}", - chrono::offset::Local::now().format("%Y-%m-%d_%H-%M-%S.tar.gz") - ); - let path = self.backup_dir.join(filename); - let tar_gz = std::fs::File::create(path)?; - let enc = GzEncoder::new(tar_gz, Compression::default()); - let mut tar = tar::Builder::new(enc); - - tar.append_dir_all("worlds", &self.world_dir)?; - - // We don't store all files in the config, as this would include caches - tar.append_path_with_name( - self.config_dir.join("server.properties"), - "config/server.properties", - )?; - - // We add a file to the backup describing for what version it was made - let info = format!("{} {}", self.type_, self.version); - let info_bytes = info.as_bytes(); - - let mut header = tar::Header::new_gnu(); - header.set_size(info_bytes.len().try_into().unwrap()); - header.set_mode(0o100644); - unsafe { - header.set_gid(getegid().into()); - header.set_uid(geteuid().into()); - } - - tar.append_data(&mut header, "info.txt", info_bytes)?; - - // tar.append_dir_all("config", &self.config_dir)?; - - // Backup file gets finalized in the drop - - Ok(()) - } - - /// Remove the oldest backups - fn remove_old_backups(&mut self) -> std::io::Result<()> { - // The naming format used allows us to sort the backups by name and still get a sorting by - // creation time - let mut backups = std::fs::read_dir(&self.backup_dir)? - .filter_map(|res| res.map(|e| e.path()).ok()) - .collect::>(); - backups.sort(); - - let max_backups: usize = self.max_backups.try_into().unwrap(); - - if backups.len() > max_backups { - let excess_backups = backups.len() - max_backups; - - for backup in &backups[0..excess_backups] { - std::fs::remove_file(backup)?; - } - } - - Ok(()) - } -}