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:
Generated
+1
@@ -1540,6 +1540,7 @@ dependencies = [
|
||||
"toml",
|
||||
"toml_edit",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¤t, &target)
|
||||
.map_err(|e| format!("moving the current world aside: {e}"))?;
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
std::fs::rename(&restored, ¤t)
|
||||
.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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user