horizon-server: backups, restore and automatic backups

`backup [NAME]` zips the world and the settings into backups/; on a running server it turns saving
off, flushes, zips and turns saving back on, so the game stays up. `restore BACKUP` puts a backup's
world in place and keeps the current one in backups/replaced-<time>/; a running server restarts with
it. `[backups] every = "6h"`, `keep = 12` in server.toml back up on a schedule and prune the
automatic backups only. The control API gains GET/POST /api/v1/backups and POST /api/v1/restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-16 20:10:58 +02:00
parent e8832190d1
commit 3510764cc6
8 changed files with 611 additions and 15 deletions
Generated
+1
View File
@@ -1540,6 +1540,7 @@ dependencies = [
"toml",
"toml_edit",
"windows-sys 0.61.2",
"zip",
]
[[package]]
+2
View File
@@ -15,6 +15,8 @@ serde_json = "1"
toml = "1.1"
# Edits server.toml in place, keeping the owner's comments and order.
toml_edit = "0.25"
# Backups: one zip anyone can open, Windows included.
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
# SIGINT/SIGTERM/SIGHUP: stop the game cleanly when the terminal or the service manager asks.
+85 -10
View File
@@ -6,8 +6,12 @@
//! - `GET /api/v1/logs?since=N`: the log lines after the Nth.
//! - `POST /api/v1/command {"line": "..."}`: a console command; the lines it logged.
//! - `POST /api/v1/stop`, `POST /api/v1/restart`.
//! - `GET /api/v1/backups`; `POST /api/v1/backups {"name": "..."}` backs up now; `POST /api/v1/restore
//! {"name": "..."}` restarts the game with that backup's world.
use crate::{
backup,
config::Config,
engine,
http::{self, Request, Response},
};
@@ -89,6 +93,10 @@ pub struct Shared {
pub log: Mutex<Log>,
pub stop: AtomicBool,
pub restart: AtomicBool,
/// The backup to put in place before the game starts again (`restore`).
pub restore: Mutex<Option<String>>,
/// Held while a backup is written: one at a time.
pub backing_up: Mutex<()>,
pub started: Instant,
}
@@ -107,12 +115,45 @@ impl Shared {
log: Mutex::new(Log::default()),
stop: AtomicBool::new(false),
restart: AtomicBool::new(false),
restore: Mutex::new(None),
backing_up: Mutex::new(()),
started: Instant::now(),
})
}
pub fn set_phase(&self, phase: Phase) {
self.state.lock().unwrap().phase = phase;
}
fn phase(&self) -> Phase {
self.state.lock().unwrap().phase
}
/// A backup while the server runs: the game saves everything and stops writing while the zip is made.
pub fn back_up(&self, file_name: &str) -> Result<PathBuf, String> {
let _one = self.backing_up.lock().unwrap();
let config = Config::load(&self.dir)?;
let mut game = match self.phase() {
Phase::Running => Some(
engine::Control::connect(&self.dir)
.map_err(|e| format!("the game didn't answer: {e}"))?,
),
// Between a crash and the restart nothing writes the world.
Phase::Restarting => None,
Phase::Starting | Phase::Stopping => {
return Err("the game is starting or stopping: try again in a moment".into());
}
};
if let Some(game) = game.as_mut() {
for command in ["console save-off", "console save-all flush"] {
game.ask(command)
.map_err(|e| format!("the game didn't save: {e}"))?;
}
}
let result = backup::create(&self.dir, &config, file_name);
if let Some(game) = game.as_mut() {
let _ = game.ask("console save-on");
}
result
}
}
fn status(shared: &Shared) -> Value {
@@ -239,10 +280,40 @@ fn route(shared: &Shared, request: &Request) -> Response {
shared.restart.store(true, Ordering::SeqCst);
Response::json(200, &json!({ "ok": true }))
}
("GET", "/api/v1/backups") => {
let list: Vec<Value> = backup::list(&shared.dir)
.into_iter()
.map(|b| json!({ "name": b.name, "bytes": b.bytes }))
.collect();
Response::json(200, &json!({ "backups": list }))
}
("POST", "/api/v1/backups") => {
let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
let made = backup::file_name(std::time::SystemTime::now(), body["name"].as_str())
.and_then(|name| shared.back_up(&name));
match made {
Ok(path) => Response::json(
200,
&json!({ "name": path.file_name().map(|n| n.to_string_lossy()) }),
),
Err(e) => Response::error(409, &e),
}
}
("POST", "/api/v1/restore") => {
let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
match backup::find(&shared.dir, body["name"].as_str().unwrap_or_default()) {
Ok(name) => {
*shared.restore.lock().unwrap() = Some(name.clone());
shared.restart.store(true, Ordering::SeqCst);
Response::json(200, &json!({ "name": name }))
}
Err(e) => Response::error(404, &e),
}
}
(
_,
"/api/v1/status" | "/api/v1/logs" | "/api/v1/command" | "/api/v1/stop"
| "/api/v1/restart",
| "/api/v1/restart" | "/api/v1/backups" | "/api/v1/restore",
) => Response::error(405, "wrong method"),
_ => Response::error(404, "no such endpoint"),
}
@@ -283,15 +354,19 @@ impl Client {
}
pub fn call(&self, method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> {
let (status, text) = http::request(
self.address,
&self.token,
method,
path,
body,
Duration::from_secs(60),
)
.map_err(|e| format!("the server didn't answer: {e}"))?;
self.call_within(method, path, body, Duration::from_secs(60))
}
/// A call that may take long (a backup of a big world).
pub fn call_within(
&self,
method: &str,
path: &str,
body: Option<&Value>,
timeout: Duration,
) -> Result<Value, String> {
let (status, text) = http::request(self.address, &self.token, method, path, body, timeout)
.map_err(|e| format!("the server didn't answer: {e}"))?;
let value: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if status == 200 {
Ok(value)
+332
View File
@@ -0,0 +1,332 @@
//! Backups and restore (docs/server.md, section 7). A backup is one zip in `backups/`: the world folder and
//! the server's settings and lists. On a running server the game first saves everything and stops writing
//! (`save-off`, `save-all`), and writes again once the zip is done (`save-on`), so players stay online.
//! A restore puts the backup's world in place of the current one, which is moved aside, never deleted.
use crate::config::Config;
use std::{
fs::File,
io::{BufWriter, Write},
path::{Path, PathBuf},
time::SystemTime,
};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
pub const DIR: &str = "backups";
/// The settings and lists a backup holds beside the world.
const FILES: [&str; 6] = [
"server.toml",
"server.properties",
"ops.json",
"whitelist.json",
"banned-players.json",
"banned-ips.json",
];
/// Automatic backups are named `<time>-auto.zip`; only those are pruned.
const AUTO: &str = "auto";
/// `2026-10-16_18-30-05`, in UTC: sorts by time, and is valid in a file name everywhere.
fn stamp(time: SystemTime) -> String {
let secs = time
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let (y, m, d) = horizon_server_files::java_properties::civil_from_days(secs.div_euclid(86_400));
let s = secs.rem_euclid(86_400);
format!(
"{y:04}-{m:02}-{d:02}_{:02}-{:02}-{:02}",
s / 3600,
s / 60 % 60,
s % 60
)
}
/// A backup's file name: the time, and a name the owner gave (letters, digits, `-` and `_` only).
pub fn file_name(time: SystemTime, name: Option<&str>) -> Result<String, String> {
let name = name.unwrap_or("manual");
if name.is_empty()
|| name.len() > 64
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!(
"backup names use letters, digits, - and _ only (got {name:?})"
));
}
Ok(format!("{}-{name}.zip", stamp(time)))
}
/// Adds a folder to the zip, under `prefix`, skipping the game's session lock.
fn add_dir(zip: &mut ZipWriter<BufWriter<File>>, dir: &Path, prefix: &str) -> Result<(), String> {
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.large_file(true);
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.flatten()
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let inner = format!("{prefix}/{name}");
if path.is_dir() {
add_dir(zip, &path, &inner)?;
} else if name != "session.lock" {
zip.start_file(inner.as_str(), options)
.map_err(|e| e.to_string())?;
let mut file = File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
std::io::copy(&mut file, zip).map_err(|e| format!("{}: {e}", path.display()))?;
}
}
Ok(())
}
/// Zips the world and the settings into `backups/<file_name>`. The zip is written under a temporary name
/// and renamed when complete, so a backup that looks finished is one.
pub fn create(dir: &Path, config: &Config, file_name: &str) -> Result<PathBuf, String> {
let backups = dir.join(DIR);
std::fs::create_dir_all(&backups).map_err(|e| format!("{}: {e}", backups.display()))?;
let path = backups.join(file_name);
let part = backups.join(format!("{file_name}.part"));
let result = (|| {
let file = File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?;
let mut zip = ZipWriter::new(BufWriter::new(file));
let world = dir.join(&config.world.name);
if world.is_dir() {
add_dir(&mut zip, &world, &config.world.name)?;
}
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for name in FILES {
if let Ok(bytes) = std::fs::read(dir.join(name)) {
zip.start_file(name, options).map_err(|e| e.to_string())?;
zip.write_all(&bytes).map_err(|e| e.to_string())?;
}
}
zip.finish()
.map_err(|e| e.to_string())?
.into_inner()
.map_err(|e| e.to_string())?
.sync_all()
.map_err(|e| e.to_string())
})();
match result {
Ok(()) => {
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
Ok(path)
}
Err(e) => {
let _ = std::fs::remove_file(&part);
Err(e)
}
}
}
pub struct Backup {
pub name: String,
pub bytes: u64,
}
/// The finished backups, newest first.
pub fn list(dir: &Path) -> Vec<Backup> {
let mut out: Vec<Backup> = std::fs::read_dir(dir.join(DIR))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.ends_with(".zip").then(|| Backup {
bytes: e.metadata().map_or(0, |m| m.len()),
name,
})
})
.collect();
out.sort_by(|a, b| b.name.cmp(&a.name));
out
}
/// Deletes the oldest automatic backups beyond `keep`. Backups made by hand are never deleted.
pub fn prune(dir: &Path, keep: usize) -> Vec<String> {
let auto_suffix = format!("-{AUTO}.zip");
list(dir)
.into_iter()
.filter(|b| b.name.ends_with(&auto_suffix))
.skip(keep)
.filter(|b| std::fs::remove_file(dir.join(DIR).join(&b.name)).is_ok())
.map(|b| b.name)
.collect()
}
pub fn auto_name(time: SystemTime) -> String {
file_name(time, Some(AUTO)).expect("the automatic name is valid")
}
/// The backup a name points at: a file in `backups/`, by its full name or the start of it.
pub fn find(dir: &Path, name: &str) -> Result<String, String> {
let all = list(dir);
if let Some(b) = all
.iter()
.find(|b| b.name == name || b.name == format!("{name}.zip"))
{
return Ok(b.name.clone());
}
let matches: Vec<&Backup> = all.iter().filter(|b| b.name.starts_with(name)).collect();
match matches.as_slice() {
[one] => return Ok(one.name.clone()),
[] => {}
_ => {
return Err(format!(
"{name:?} matches {} backups: be more precise",
matches.len()
));
}
}
// The name given to `backup NAME` (or `auto`, `manual`): the newest backup with it.
let label = format!("-{name}.zip");
all.iter()
.find(|b| b.name.ends_with(&label))
.map(|b| b.name.clone())
.ok_or_else(|| {
format!("no backup {name:?} in {DIR}/ (`horizon-server backups` lists them)")
})
}
/// Puts the backup's world in place of the current one, which goes to `backups/replaced-<time>/`. The
/// server must be stopped. Returns where the old world went.
pub fn restore(dir: &Path, config: &Config, name: &str) -> Result<Option<PathBuf>, String> {
let zip_path = dir.join(DIR).join(name);
let scratch = dir.join(".horizon/restore");
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).map_err(|e| e.to_string())?;
let file = File::open(&zip_path).map_err(|e| format!("{}: {e}", zip_path.display()))?;
ZipArchive::new(file)
.and_then(|mut z| z.extract(&scratch))
.map_err(|e| format!("{name}: {e}"))?;
let world = &config.world.name;
let restored = scratch.join(world);
if !restored.join("level.dat").is_file() {
let _ = std::fs::remove_dir_all(&scratch);
return Err(format!(
"{name} holds no world named {world:?} (no {world}/level.dat)"
));
}
let current = dir.join(world);
let moved = if current.exists() {
let aside = dir
.join(DIR)
.join(format!("replaced-{}", stamp(SystemTime::now())));
std::fs::create_dir_all(&aside).map_err(|e| e.to_string())?;
let target = aside.join(world);
std::fs::rename(&current, &target)
.map_err(|e| format!("moving the current world aside: {e}"))?;
Some(target)
} else {
None
};
std::fs::rename(&restored, &current)
.map_err(|e| format!("putting the backup in place: {e}"))?;
let _ = std::fs::remove_dir_all(&scratch);
Ok(moved)
}
/// `every` from server.toml: `"6h"`, `"30m"`, `"1d"`, or `"0"` for no automatic backups.
pub fn interval(every: &str) -> Result<Option<std::time::Duration>, String> {
let every = every.trim();
if every == "0" || every.is_empty() {
return Ok(None);
}
let (number, unit) = every.split_at(every.len() - 1);
let n: u64 = number.trim().parse().map_err(|_| {
format!("backups.every is {every:?}: use a number and m, h or d, like \"6h\"")
})?;
let secs = match unit {
"m" => 60,
"h" => 3600,
"d" => 86_400,
_ => {
return Err(format!(
"backups.every is {every:?}: use a number and m, h or d, like \"6h\""
));
}
};
Ok((n > 0).then(|| std::time::Duration::from_secs(n * secs)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_sort_by_time() {
let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_792_000_000);
assert_eq!(
file_name(t, Some("before-update")).unwrap(),
"2026-10-14_17-46-40-before-update.zip"
);
assert!(file_name(t, Some("../x")).is_err());
assert_eq!(auto_name(t), "2026-10-14_17-46-40-auto.zip");
}
#[test]
fn intervals() {
assert_eq!(
interval("6h").unwrap(),
Some(std::time::Duration::from_secs(21_600))
);
assert_eq!(
interval("30m").unwrap(),
Some(std::time::Duration::from_secs(1800))
);
assert_eq!(interval("0").unwrap(), None);
assert!(interval("6 hours").is_err());
}
#[test]
fn back_up_and_restore() {
let dir =
std::env::temp_dir().join(format!("horizon-server-backup-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = Config::default();
let world = dir.join("world");
std::fs::create_dir_all(world.join("region")).unwrap();
std::fs::write(world.join("level.dat"), b"before").unwrap();
std::fs::write(world.join("region/r.0.0.mca"), vec![7u8; 10_000]).unwrap();
std::fs::write(world.join("session.lock"), b"x").unwrap();
std::fs::write(dir.join("server.toml"), crate::config::TEMPLATE).unwrap();
let t = SystemTime::now();
let name = file_name(t, None).unwrap();
create(&dir, &config, &name).unwrap();
assert_eq!(list(&dir).len(), 1);
// The world changes, then the backup comes back.
std::fs::write(world.join("level.dat"), b"after").unwrap();
let moved = restore(&dir, &config, &find(&dir, &name[..10]).unwrap())
.unwrap()
.unwrap();
assert_eq!(std::fs::read(world.join("level.dat")).unwrap(), b"before");
assert_eq!(
std::fs::read(world.join("region/r.0.0.mca")).unwrap().len(),
10_000
);
assert!(!world.join("session.lock").exists());
assert_eq!(std::fs::read(moved.join("level.dat")).unwrap(), b"after");
// Only automatic backups are pruned.
for i in 0..3 {
let later = t + std::time::Duration::from_secs(10 + i);
create(&dir, &config, &auto_name(later)).unwrap();
}
assert_eq!(prune(&dir, 1).len(), 2);
let left: Vec<String> = list(&dir).into_iter().map(|b| b.name).collect();
assert_eq!(left.len(), 2);
assert!(left.iter().any(|n| n.ends_with("-manual.zip")));
// A backup is found by its name, or the newest with a label.
assert_eq!(find(&dir, "manual").unwrap(), name);
assert_eq!(find(&dir, &left[0]).unwrap(), left[0]);
assert_eq!(
find(&dir, left[0].trim_end_matches(".zip")).unwrap(),
left[0]
);
assert_eq!(find(&dir, "auto").unwrap(), left[0]);
assert!(find(&dir, "nothing").is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+87 -1
View File
@@ -1,7 +1,7 @@
//! The commands that manage a server in the background (docs/server.md, section 5): `start`, `stop`,
//! `restart`, `status`, `cmd` and `logs`. They talk to the running supervisor through the control API.
use crate::{api::Client, signals, supervisor};
use crate::{api::Client, backup, signals, supervisor};
use serde_json::{Value, json};
use std::{
path::Path,
@@ -252,6 +252,92 @@ fn duration(secs: u64) -> String {
}
}
/// `backup [NAME]`: through the running server (it saves first), or straight from the files.
pub fn backup(dir: &Path, name: Option<&str>) -> Result<(), String> {
let made = match Client::open(dir)? {
Some(client) => client.call_within(
"POST",
"/api/v1/backups",
Some(&json!({ "name": name })),
Duration::from_secs(3600),
)?["name"]
.as_str()
.unwrap_or_default()
.to_owned(),
None => {
let _lock = supervisor::lock(dir)?;
let config = crate::config::Config::load(dir)?;
let file = backup::file_name(std::time::SystemTime::now(), name)?;
backup::create(dir, &config, &file)?;
file
}
};
println!("Backed up to {}/{made}", backup::DIR);
Ok(())
}
/// `backups`: the backups, newest first.
pub fn backups(dir: &Path) -> Result<(), String> {
let all = backup::list(dir);
if all.is_empty() {
println!("No backups yet: `horizon-server backup` makes one.");
}
for b in all {
println!("{:>10} {}", size(b.bytes), b.name);
}
Ok(())
}
fn size(bytes: u64) -> String {
match bytes {
0..1_000_000 => format!("{} kB", bytes.div_ceil(1000)),
1_000_000..1_000_000_000 => format!("{:.1} MB", bytes as f64 / 1e6),
_ => format!("{:.2} GB", bytes as f64 / 1e9),
}
}
/// `restore NAME [--yes]`: the backup's world in place of the current one (moved aside, not deleted). A
/// running server restarts with it.
pub fn restore(dir: &Path, name: &str, yes: bool) -> Result<(), String> {
let name = backup::find(dir, name)?;
if !yes {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Err("restore replaces the world: pass --yes to confirm".into());
}
print!(
"Replace the world with {name}? The current world is kept in {}/. [y/N] ",
backup::DIR
);
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut answer = String::new();
let _ = std::io::stdin().read_line(&mut answer);
if !answer.trim().eq_ignore_ascii_case("y") && !answer.trim().eq_ignore_ascii_case("yes") {
println!("Nothing changed.");
return Ok(());
}
}
if let Some(client) = Client::open(dir)? {
let before = client.call("GET", "/api/v1/status", None)?["engine_pid"].as_u64();
client.call("POST", "/api/v1/restore", Some(&json!({ "name": name })))?;
print!("Restoring {name}: restarting the game with it");
wait_running(&client, before)?;
println!(
"\nThe game is running on the restored world; `horizon-server logs` has the details."
);
return Ok(());
}
let _lock = supervisor::lock(dir)?;
let config = crate::config::Config::load(dir)?;
match backup::restore(dir, &config, &name)? {
Some(old) => println!(
"Restored {name}; the world it replaced is in {}",
old.display()
),
None => println!("Restored {name}"),
}
Ok(())
}
/// `logs [-f] [-n N]`: the last lines, from the running server or else from logs/latest.log.
pub fn logs(dir: &Path, follow: bool, count: usize) -> Result<(), String> {
let Some(client) = Client::open(dir)? else {
+31 -2
View File
@@ -44,6 +44,10 @@ hardcore = false
path = "" # the game program; empty: the one installed beside horizon-server
resources = "" # the prepared game files; empty: resources/ here, else the usual places
restart_on_crash = true
[backups]
every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off
keep = 12 # automatic backups kept (backups made by hand are never deleted)
"##;
/// Each typed setting: its section, its name, and the vanilla key it writes.
@@ -75,6 +79,8 @@ pub struct Config {
pub properties: BTreeMap<String, toml::Value>,
#[serde(default)]
pub engine: Engine,
#[serde(default)]
pub backups: Backups,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -111,6 +117,13 @@ pub struct Engine {
pub restart_on_crash: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Backups {
pub every: String,
pub keep: u32,
}
impl Default for Server {
fn default() -> Self {
Self {
@@ -147,6 +160,15 @@ impl Default for Engine {
}
}
impl Default for Backups {
fn default() -> Self {
Self {
every: "6h".into(),
keep: 12,
}
}
}
fn text_or_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
@@ -196,6 +218,7 @@ impl Config {
w.name
));
}
crate::backup::interval(&self.backups.every)?;
if self.server.port == 0 {
return Err("server.port must be a port number from 1 to 65535".into());
}
@@ -291,8 +314,14 @@ pub fn render(config: &Config) -> String {
for (key, value) in &config.properties {
set(&mut doc, "properties", key, value);
}
for field in ["path", "resources", "restart_on_crash"] {
set(&mut doc, "engine", field, &table["engine"][field]);
for (section, field) in [
("engine", "path"),
("engine", "resources"),
("engine", "restart_on_crash"),
("backups", "every"),
("backups", "keep"),
] {
set(&mut doc, section, field, &table[section][field]);
}
doc.to_string()
}
+13
View File
@@ -2,6 +2,7 @@
//! runs the game headless as the engine, and manages the vanilla files around it.
mod api;
mod backup;
mod cli;
mod config;
mod engine;
@@ -31,6 +32,12 @@ Usage: horizon-server [--dir DIR] <command>
Run a console command on the running server and print what it answers
logs [-f] [-n N]
The last N lines of the log (default 50); -f follows it
backup [NAME]
Zip the world and the settings into backups/ (a running server saves first and stays up)
backups List the backups
restore BACKUP [--yes]
Put a backup's world in place (the current one is kept in backups/); a running
server restarts with it
import FILE Write server.toml from a vanilla server.properties
op NAME, deop NAME
whitelist on|off|list, whitelist add|remove NAME
@@ -69,6 +76,12 @@ fn main() -> ExitCode {
Some("status") => cli::status(&dir, args[1..].iter().any(|a| a == "--json")),
Some("cmd") => cli::command(&dir, &args[1..]),
Some("logs") => logs(&dir, &args[1..]),
Some("backup") => cli::backup(&dir, args.get(1).map(String::as_str)),
Some("backups") => cli::backups(&dir),
Some("restore") => match args.get(1) {
Some(name) => cli::restore(&dir, name, args[2..].iter().any(|a| a == "--yes")),
None => Err("restore needs a backup (`horizon-server backups` lists them)".into()),
},
Some("import") => import(&dir, &args[1..]),
Some(command @ ("op" | "deop" | "whitelist")) => lists::run(&dir, command, &args[1..]),
Some(other) => Err(format!(
+60 -2
View File
@@ -4,7 +4,7 @@
use crate::{
api::{self, Phase, Shared},
config,
backup, config,
config::Config,
engine, signals,
};
@@ -80,6 +80,7 @@ pub fn run(dir: &Path) -> Result<(), String> {
let shared = Shared::new(dir);
let _endpoint = api::start(shared.clone())?;
sample(shared.clone());
schedule_backups(shared.clone());
let console = console_lines();
let mut crashes: Vec<Instant> = Vec::new();
loop {
@@ -129,7 +130,24 @@ pub fn run(dir: &Path) -> Result<(), String> {
}
let status = match exit {
Exit::Stopped => return Ok(()),
Exit::Restart => continue,
Exit::Restart => {
let restore = shared.restore.lock().unwrap().take();
if let Some(name) = restore {
match backup::restore(dir, &config, &name) {
Ok(Some(old)) => note(
&shared,
"INFO",
&format!(
"Restored {name}; the world it replaced is in {}",
old.display()
),
),
Ok(None) => note(&shared, "INFO", &format!("Restored {name}")),
Err(e) => note(&shared, "ERROR", &format!("Could not restore {name}: {e}")),
}
}
continue;
}
Exit::Crashed(status) => status,
};
{
@@ -271,6 +289,46 @@ fn sample(shared: Arc<Shared>) {
});
}
/// Automatic backups every `backups.every` while the game runs, keeping the last `backups.keep`.
fn schedule_backups(shared: Arc<Shared>) {
let _ = thread::Builder::new()
.name("backups".into())
.spawn(move || {
let mut last = Instant::now();
loop {
thread::sleep(Duration::from_secs(30));
let Ok(config) = Config::load(&shared.dir) else {
continue;
};
let Ok(Some(every)) = backup::interval(&config.backups.every) else {
continue;
};
if last.elapsed() < every || shared.state.lock().unwrap().phase != Phase::Running {
continue;
}
last = Instant::now();
match shared.back_up(&backup::auto_name(std::time::SystemTime::now())) {
Ok(path) => {
note(&shared, "INFO", &format!("Backed up to {}", path.display()));
let pruned = backup::prune(&shared.dir, config.backups.keep as usize);
if !pruned.is_empty() {
note(
&shared,
"INFO",
&format!("Deleted older backups: {}", pruned.join(", ")),
);
}
}
Err(e) => note(
&shared,
"WARN",
&format!("The automatic backup failed: {e}"),
),
}
}
});
}
/// Runs one engine process to its end: console lines in, the log out, and a clean `stop` when asked.
fn supervise(shared: &Arc<Shared>, child: &mut Child, console: &mpsc::Receiver<String>) -> Exit {
if let Some(out) = child.stdout.take() {