mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-13 04:21:02 +00:00
Phase 4: recovery points, zstd DB dumps, restore via staging schema + atomic table swap, Backups UI
Verified on fixture MySQL: backup, restore (previous schema kept), damaged backup refused; live install untouched. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b5255cca8
commit
217eec338a
13 files changed
+1130
-3
No files matched your search
Generated
+41
@@ -286,6 +286,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -354,6 +356,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windows-sys 0.60.2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1750,6 +1753,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -4802,3 +4815,31 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
@@ -9,7 +9,9 @@ Design and audit: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
| 0 Audit + architecture | done |
|
||||
| 1 Foundation (fsx, registry, manifest, logging, error catalogue) | done |
|
||||
| 2 Read-only import, process observation, Start/Stop via repack launcher, health diagnosis | done |
|
||||
| 3 Config UI, 4 Backups, 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started |
|
||||
| 3 Config engine + Bots/Server settings UI + presets + snapshots | done |
|
||||
| 4 Recovery points, DB dump/restore (staging + atomic swap), Backups UI | done |
|
||||
| 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started |
|
||||
|
||||
## Develop
|
||||
```
|
||||
|
||||
@@ -16,6 +16,7 @@ fs4 = "0.13"
|
||||
tracing = "0.1"
|
||||
ed25519-dalek = "2"
|
||||
base64 = "0.22"
|
||||
zstd = "0.13"
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Full backup/restore scenario against a DISPOSABLE fixture (never point this at a real server).
|
||||
use coa_core::backup::{self, Kind, Trigger};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::args().nth(1).expect("usage: backup_e2e <fixture folder>");
|
||||
assert!(root.to_lowercase().contains("fixture"), "refusing: not a fixture folder");
|
||||
let root = Path::new(&root);
|
||||
let meta = coa_core::registry::metadata_dir_for(root).unwrap();
|
||||
std::fs::create_dir_all(&meta).unwrap();
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let point = backup::create(root, &meta, Kind::Quick, Trigger::Manual, Some("e2e".into()), &|s| println!(" .. {s}")).expect("backup");
|
||||
println!("quick backup {} in {:?}: {:?}", point.id, t.elapsed(), point.components.iter().map(|c| (&c.name, c.bytes, c.tables)).collect::<Vec<_>>());
|
||||
assert!(backup::verify(&meta, &point.id).unwrap().ok);
|
||||
|
||||
// Change live state after the backup.
|
||||
backup::with_database(root, |db| {
|
||||
db.query("CREATE TABLE acore_characters.manager_marker (id INT); INSERT INTO acore_characters.manager_marker VALUES (42);")?;
|
||||
assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.manager_marker;")?, "1");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let r = backup::restore_database(root, &meta, &point.id, "characters").expect("restore");
|
||||
println!("restored {} tables; previous database kept as {}; safety backup {}", r.tables_restored, r.previous_schema, r.safety_backup);
|
||||
|
||||
backup::with_database(root, |db| {
|
||||
let live = db.tables("acore_characters")?;
|
||||
assert!(!live.contains(&"manager_marker".to_string()), "marker must be gone from the restored database");
|
||||
let old = db.tables(&r.previous_schema)?;
|
||||
assert!(old.contains(&"manager_marker".to_string()), "the replaced database keeps the marker");
|
||||
assert_eq!(db.query(&format!("SELECT id FROM {}.manager_marker;", r.previous_schema))?, "42");
|
||||
assert_eq!(live.len(), r.tables_restored);
|
||||
println!("OK: live tables {}, old schema tables {}", live.len(), old.len());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// A rejected restore (server "running" is simulated by a damaged backup) must change nothing.
|
||||
let victim = meta.join("backups").join(&point.id).join("characters.sql.zst");
|
||||
let mut bytes = std::fs::read(&victim).unwrap();
|
||||
bytes[10] ^= 0xFF;
|
||||
std::fs::write(&victim, bytes).unwrap();
|
||||
assert!(backup::verify(&meta, &point.id).unwrap().problems.len() == 1);
|
||||
assert!(backup::restore_database(root, &meta, &point.id, "characters").is_err());
|
||||
println!("damaged backup correctly refused");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ fn main() {
|
||||
let root = args.next().expect("usage: drive <server folder> <start|stop>");
|
||||
let verb = match args.next().as_deref() {
|
||||
Some("start") => coa_core::driver::Verb::StartAll,
|
||||
Some("mysql") => coa_core::driver::Verb::StartMysql,
|
||||
Some("stop") => coa_core::driver::Verb::StopAll,
|
||||
_ => panic!("verb must be start or stop"),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
//! Recovery points: configuration + database dumps stored under `<install>.manager/backups/<id>/`.
|
||||
//!
|
||||
//! Rules enforced here: a recovery point only becomes visible once complete (built in a `.partial` folder),
|
||||
//! every artefact is checksummed, restore never drops anything (the replaced database is kept under a new name)
|
||||
//! and restoring a database requires the game servers to be stopped.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::{self, Account, Db};
|
||||
use crate::driver::{self, Verb};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fsx;
|
||||
use crate::layout::read_ports;
|
||||
use crate::process::{self, ServiceState};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Kind {
|
||||
/// characters + auth databases, configs, manager metadata.
|
||||
Quick,
|
||||
/// Quick + the (large) world database.
|
||||
Full,
|
||||
Config,
|
||||
/// characters + auth databases only.
|
||||
Database,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Trigger {
|
||||
Manual,
|
||||
Automatic,
|
||||
BeforeUpdate,
|
||||
BeforeRestore,
|
||||
BeforeMigration,
|
||||
BeforeBots,
|
||||
BeforeRepair,
|
||||
BeforeDangerousChange,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Component {
|
||||
/// "characters" | "auth" | "world" | "configs"
|
||||
pub name: String,
|
||||
/// File (databases) or folder (configs), relative to the recovery point.
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
pub sha256: Option<String>,
|
||||
pub tables: Option<usize>,
|
||||
pub files: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecoveryPoint {
|
||||
pub schema: u32,
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
pub trigger: Trigger,
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
pub manager_version: String,
|
||||
pub components: Vec<Component>,
|
||||
}
|
||||
|
||||
fn backups_dir(meta: &Path) -> PathBuf {
|
||||
meta.join("backups")
|
||||
}
|
||||
|
||||
fn id_ok(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() < 100 && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !id.contains("..")
|
||||
}
|
||||
|
||||
fn point_dir(meta: &Path, id: &str) -> Result<PathBuf> {
|
||||
if !id_ok(id) {
|
||||
return Err(Error::PathRejected(format!("bad backup id {id:?}")));
|
||||
}
|
||||
Ok(backups_dir(meta).join(id))
|
||||
}
|
||||
|
||||
pub fn list(meta: &Path) -> Vec<RecoveryPoint> {
|
||||
let mut out: Vec<RecoveryPoint> = fs::read_dir(backups_dir(meta))
|
||||
.map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
out.sort_by(|a, b| b.id.cmp(&a.id));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get(meta: &Path, id: &str) -> Result<RecoveryPoint> {
|
||||
fsx::read_json(&point_dir(meta, id)?.join("backup.json")).map_err(|_| Error::Invalid(format!("backup {id} was not found")))
|
||||
}
|
||||
|
||||
/// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets.
|
||||
pub fn config_files(root: &Path) -> Vec<String> {
|
||||
const MAX: u64 = 8 * 1024 * 1024;
|
||||
let mut out = Vec::new();
|
||||
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
||||
let Ok(rd) = fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let Ok(md) = e.metadata() else { continue };
|
||||
if md.is_dir() {
|
||||
walk(root, &p, out);
|
||||
} else if md.len() <= MAX {
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
|
||||
if name.ends_with(".bak") || name.ends_with(".orig") || name.ends_with(".old") || name.contains(".bak-") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(rel) = p.strip_prefix(root) {
|
||||
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, &root.join("Core/configs"), &mut out);
|
||||
if let Ok(rd) = fs::read_dir(root.join("Settings")) {
|
||||
for e in rd.flatten() {
|
||||
let n = e.file_name().to_string_lossy().to_string();
|
||||
// database.json holds generated database passwords; it is launcher state, not configuration.
|
||||
if e.path().is_file() && (n.contains(".template") || n == "repack.json") {
|
||||
out.push(format!("Settings/{n}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.join("RELEASE.json").is_file() {
|
||||
out.push("RELEASE.json".into());
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn copy_configs(root: &Path, dir: &Path) -> Result<Component> {
|
||||
let files = config_files(root);
|
||||
let target = dir.join("files");
|
||||
let mut bytes = 0;
|
||||
for rel in &files {
|
||||
let src = fsx::safe_join(root, rel)?;
|
||||
let dst = fsx::safe_join(&target, rel)?;
|
||||
fs::create_dir_all(dst.parent().unwrap())?;
|
||||
bytes += fs::copy(&src, &dst)?;
|
||||
}
|
||||
Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files) })
|
||||
}
|
||||
|
||||
/// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again.
|
||||
pub fn with_database<T>(root: &Path, f: impl FnOnce(&Db) -> Result<T>) -> Result<T> {
|
||||
let db = Db::from_repack(root, Account::Admin)?;
|
||||
let observed = process::observe(root, &read_ports(root));
|
||||
let mysql_was_up = observed.mysql.state == ServiceState::Running && db.ping();
|
||||
if !mysql_was_up {
|
||||
let out = driver::run(root, Verb::StartMysql)?;
|
||||
if !out.ok {
|
||||
return Err(Error::Invalid(out.human.map(|h| h.title.to_string()).unwrap_or_else(|| "The database could not be started.".into())));
|
||||
}
|
||||
}
|
||||
let result = f(&db);
|
||||
if !mysql_was_up {
|
||||
let now = process::observe(root, &read_ports(root));
|
||||
if now.world.state == ServiceState::Stopped && now.auth.state == ServiceState::Stopped {
|
||||
let _ = driver::run(root, Verb::StopAll);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn wanted_databases(kind: Kind) -> &'static [&'static str] {
|
||||
match kind {
|
||||
Kind::Quick | Kind::Database => &["characters", "auth"],
|
||||
Kind::Full => &["characters", "auth", "world"],
|
||||
Kind::Config => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a recovery point. The server may be running: dumps are consistent snapshots (`--single-transaction`).
|
||||
pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Option<String>, progress: &dyn Fn(&str)) -> Result<RecoveryPoint> {
|
||||
let id = format!("{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), match trigger {
|
||||
Trigger::Manual => "manual",
|
||||
Trigger::Automatic => "auto",
|
||||
Trigger::BeforeUpdate => "before-update",
|
||||
Trigger::BeforeRestore => "before-restore",
|
||||
Trigger::BeforeMigration => "before-migration",
|
||||
Trigger::BeforeBots => "before-bots",
|
||||
Trigger::BeforeRepair => "before-repair",
|
||||
Trigger::BeforeDangerousChange => "before-change",
|
||||
});
|
||||
let final_dir = point_dir(meta, &id)?;
|
||||
let partial = backups_dir(meta).join(format!("{id}.partial"));
|
||||
fs::create_dir_all(&partial)?;
|
||||
|
||||
let build = || -> Result<Vec<Component>> {
|
||||
let mut components = Vec::new();
|
||||
for name in wanted_databases(kind) {
|
||||
let schema = db::schema_of(name)?;
|
||||
progress(&format!("Backing up {name} database"));
|
||||
let component = with_database(root, |db| {
|
||||
let size = db.schema_bytes(schema)?;
|
||||
fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?;
|
||||
let tables = db.tables(schema)?.len();
|
||||
let file = format!("{name}.sql.zst");
|
||||
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
|
||||
Ok(Component { name: (*name).into(), path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
|
||||
})?;
|
||||
components.push(component);
|
||||
}
|
||||
if kind != Kind::Database {
|
||||
progress("Saving configuration");
|
||||
components.push(copy_configs(root, &partial)?);
|
||||
}
|
||||
Ok(components)
|
||||
};
|
||||
|
||||
let components = match build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&partial); // our own unfinished folder
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let point = RecoveryPoint {
|
||||
schema: 1,
|
||||
id: id.clone(),
|
||||
kind,
|
||||
trigger,
|
||||
label,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
manager_version: crate::MANAGER_VERSION.into(),
|
||||
components,
|
||||
};
|
||||
fsx::atomic_write_json(&partial.join("backup.json"), &point)?;
|
||||
fs::rename(&partial, &final_dir)?;
|
||||
tracing::info!(%id, ?kind, ?trigger, "recovery point created");
|
||||
if trigger == Trigger::Automatic {
|
||||
prune_automatic(meta, 10);
|
||||
}
|
||||
Ok(point)
|
||||
}
|
||||
|
||||
/// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned.
|
||||
pub fn prune_automatic(meta: &Path, keep: usize) {
|
||||
let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect();
|
||||
for p in autos.into_iter().skip(keep) {
|
||||
if let Ok(dir) = point_dir(meta, &p.id) {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VerifyReport {
|
||||
pub ok: bool,
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
|
||||
let point = get(meta, id)?;
|
||||
let dir = point_dir(meta, id)?;
|
||||
let mut problems = Vec::new();
|
||||
for c in &point.components {
|
||||
let path = dir.join(&c.path);
|
||||
match (&c.sha256, &c.files) {
|
||||
(Some(sha), _) => match fsx::sha256_file(&path) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(sha) => {}
|
||||
Ok(_) => problems.push(format!("{} is damaged (checksum mismatch)", c.name)),
|
||||
Err(_) => problems.push(format!("{} is missing", c.name)),
|
||||
},
|
||||
(None, Some(files)) => {
|
||||
for f in files {
|
||||
if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) {
|
||||
problems.push(format!("configuration file {f} is missing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(VerifyReport { ok: problems.is_empty(), problems })
|
||||
}
|
||||
|
||||
/// Delete one recovery point (its own folder only).
|
||||
pub fn delete(meta: &Path, id: &str) -> Result<()> {
|
||||
let dir = point_dir(meta, id)?;
|
||||
get(meta, id)?; // must be a real recovery point
|
||||
fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first.
|
||||
pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result<RecoveryPoint> {
|
||||
let point = get(meta, id)?;
|
||||
let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?;
|
||||
if !verify(meta, id)?.ok {
|
||||
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
||||
}
|
||||
let safety = create(root, meta, Kind::Config, Trigger::BeforeRestore, Some(format!("before restoring {id}")), &|_| {})?;
|
||||
let src_root = point_dir(meta, id)?.join(&comp.path);
|
||||
for rel in comp.files.as_deref().unwrap_or_default() {
|
||||
let dst = fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?;
|
||||
fsx::atomic_write(&dst, &fs::read(fsx::safe_join(&src_root, rel)?)?)?;
|
||||
}
|
||||
Ok(safety)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DbRestore {
|
||||
/// The database the restore replaced, kept intact under this name.
|
||||
pub previous_schema: String,
|
||||
pub safety_backup: String,
|
||||
pub tables_restored: usize,
|
||||
}
|
||||
|
||||
/// Restore one database from a recovery point without dropping anything:
|
||||
/// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema.
|
||||
pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result<DbRestore> {
|
||||
let point = get(meta, id)?;
|
||||
let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?;
|
||||
if !verify(meta, id)?.ok {
|
||||
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
||||
}
|
||||
let now = process::observe(root, &read_ports(root));
|
||||
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped {
|
||||
return Err(Error::Invalid("Stop the server before restoring a database.".into()));
|
||||
}
|
||||
let live = db::schema_of(name)?;
|
||||
let expected_tables = comp.tables.unwrap_or(0);
|
||||
let dump = point_dir(meta, id)?.join(&comp.path);
|
||||
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||
|
||||
// 1. Safety copy of the current state, so even a wrong restore is reversible.
|
||||
let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?;
|
||||
|
||||
with_database(root, |db| {
|
||||
let staging = format!("{live}_restore_{stamp}");
|
||||
let old = format!("{live}_before_restore_{stamp}");
|
||||
if db.schema_exists(&staging)? || db.schema_exists(&old)? {
|
||||
return Err(Error::Invalid("A previous restore left its work schemas behind; try again in a moment.".into()));
|
||||
}
|
||||
// 2. Import into staging.
|
||||
db.query(&format!("CREATE DATABASE `{staging}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?;
|
||||
db.import_from(&staging, &dump)?;
|
||||
// 3. Sanity checks.
|
||||
let staged = db.tables(&staging)?;
|
||||
if staged.is_empty() || (expected_tables > 0 && staged.len() != expected_tables) {
|
||||
return Err(Error::Invalid(format!("The restored copy looks incomplete ({} of {expected_tables} tables); nothing was changed.", staged.len())));
|
||||
}
|
||||
if db.extra_objects(&staging)? > 0 || db.extra_objects(live)? > 0 {
|
||||
return Err(Error::Invalid("This database contains routines, triggers or views; automatic restore does not support that. Nothing was changed.".into()));
|
||||
}
|
||||
// 4. Atomic swap: live tables move to the `before_restore` schema, staged tables move into place.
|
||||
let current = db.tables(live)?;
|
||||
db.query(&format!("CREATE DATABASE `{old}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?;
|
||||
let mut renames = Vec::new();
|
||||
for t in ¤t {
|
||||
renames.push(format!("`{live}`.`{t}` TO `{old}`.`{t}`"));
|
||||
}
|
||||
for t in &staged {
|
||||
renames.push(format!("`{staging}`.`{t}` TO `{live}`.`{t}`"));
|
||||
}
|
||||
db.query(&format!("RENAME TABLE {};", renames.join(", ")))?;
|
||||
Ok(DbRestore { previous_schema: old, safety_backup: safety.id.clone(), tables_restored: staged.len() })
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("srv");
|
||||
let meta = dir.path().join("srv.manager");
|
||||
crate::layout::testkit::fake_repack(&root);
|
||||
fs::write(root.join("Settings/database.json"), r#"{"rootPassword":"secret-root","appPassword":"secret-app"}"#).unwrap_or_else(|_| {
|
||||
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||
fs::write(root.join("Settings/database.json"), "{}").unwrap();
|
||||
});
|
||||
fs::create_dir_all(&meta).unwrap();
|
||||
(dir, root, meta)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() {
|
||||
let (_d, root, meta) = setup();
|
||||
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||
fs::write(root.join("Settings/database.json"), r#"{"rootPassword":"x"}"#).unwrap();
|
||||
fs::write(root.join("Settings/worldserver.conf.template"), "A = 1\n").unwrap();
|
||||
fs::write(root.join("Core/configs/modules/old.conf.bak"), "junk").unwrap();
|
||||
let files = config_files(&root);
|
||||
assert!(files.contains(&"Core/configs/worldserver.conf".to_string()));
|
||||
assert!(files.contains(&"Settings/worldserver.conf.template".to_string()));
|
||||
assert!(!files.iter().any(|f| f.contains("database.json")), "secrets excluded");
|
||||
assert!(!files.iter().any(|f| f.ends_with(".bak")));
|
||||
|
||||
let p = create(&root, &meta, Kind::Config, Trigger::Manual, Some("test".into()), &|_| {}).unwrap();
|
||||
assert!(verify(&meta, &p.id).unwrap().ok);
|
||||
assert_eq!(list(&meta).len(), 1);
|
||||
assert!(!backups_dir(&meta).join(format!("{}.partial", p.id)).exists());
|
||||
|
||||
let original = fs::read(root.join("Core/configs/worldserver.conf")).unwrap();
|
||||
fs::write(root.join("Core/configs/worldserver.conf"), "[worldserver]\nRealmID = 99\n").unwrap();
|
||||
fs::write(root.join("Core/configs/unrelated_new.conf"), "keep").unwrap();
|
||||
let safety = restore_configs(&root, &meta, &p.id).unwrap();
|
||||
assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), original);
|
||||
assert!(root.join("Core/configs/unrelated_new.conf").is_file(), "restore never deletes other files");
|
||||
assert_eq!(safety.trigger, Trigger::BeforeRestore);
|
||||
assert!(list(&meta).len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_detects_tampering_and_restore_refuses_damaged_backup() {
|
||||
let (_d, root, meta) = setup();
|
||||
let p = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap();
|
||||
let victim = point_dir(&meta, &p.id).unwrap().join("files/Core/configs/worldserver.conf");
|
||||
fs::remove_file(&victim).unwrap();
|
||||
let v = verify(&meta, &p.id).unwrap();
|
||||
assert!(!v.ok && v.problems[0].contains("worldserver.conf"));
|
||||
assert!(restore_configs(&root, &meta, &p.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_validated_and_delete_only_touches_real_backups() {
|
||||
let (d, root, meta) = setup();
|
||||
for bad in ["", "..", "../x", "a/b", "a\\b"] {
|
||||
assert!(point_dir(&meta, bad).is_err(), "{bad:?}");
|
||||
}
|
||||
let victim = d.path().join("srv.manager/important");
|
||||
fs::create_dir_all(&victim).unwrap();
|
||||
assert!(delete(&meta, "important").is_err(), "not a recovery point");
|
||||
assert!(victim.exists());
|
||||
let p = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap();
|
||||
delete(&meta, &p.id).unwrap();
|
||||
assert!(list(&meta).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_points_are_pruned_but_manual_and_safety_are_kept() {
|
||||
let (_d, root, meta) = setup();
|
||||
let mk = |t: Trigger, id: &str| {
|
||||
let mut p = create(&root, &meta, Kind::Config, t, None, &|_| {}).unwrap();
|
||||
// give each a distinct, sortable id by renaming its folder
|
||||
let from = point_dir(&meta, &p.id).unwrap();
|
||||
p.id = id.to_string();
|
||||
fsx::atomic_write_json(&from.join("backup.json"), &p).unwrap();
|
||||
fs::rename(from, backups_dir(&meta).join(id)).unwrap();
|
||||
};
|
||||
mk(Trigger::Manual, "20260101-000000-manual");
|
||||
mk(Trigger::BeforeRestore, "20260102-000000-before-restore");
|
||||
for i in 0..5 {
|
||||
mk(Trigger::Automatic, &format!("2026020{i}-000000-auto"));
|
||||
}
|
||||
prune_automatic(&meta, 2);
|
||||
let left: Vec<String> = list(&meta).into_iter().map(|p| p.id).collect();
|
||||
assert_eq!(left.iter().filter(|i| i.ends_with("-auto")).count(), 2);
|
||||
assert!(left.contains(&"20260101-000000-manual".to_string()));
|
||||
assert!(left.contains(&"20260102-000000-before-restore".to_string()));
|
||||
assert!(left.contains(&"20260204-000000-auto".to_string()), "newest automatic kept");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Database access through the MySQL client tools bundled with the repack (`mysql`, `mysqldump`, `mysqladmin`).
|
||||
//!
|
||||
//! Passwords travel only in the child's environment (`MYSQL_PWD`), never on a command line or in a log.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, ErrorCode, Result};
|
||||
use crate::fsx;
|
||||
use crate::layout::{read_ports, Ports};
|
||||
|
||||
/// A credential that never prints itself.
|
||||
#[derive(Clone)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Secret(***)")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Account {
|
||||
/// Owner of everything in the packaged MySQL (needed for staging schemas and renames).
|
||||
Admin,
|
||||
/// The game servers' own account.
|
||||
App,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Db {
|
||||
bin: PathBuf,
|
||||
port: u16,
|
||||
user: &'static str,
|
||||
password: Secret,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Credentials {
|
||||
root_password: String,
|
||||
app_password: String,
|
||||
}
|
||||
|
||||
fn no_window(cmd: &mut Command) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = cmd;
|
||||
}
|
||||
|
||||
/// Schema names of the three game databases.
|
||||
pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")];
|
||||
|
||||
pub fn schema_of(kind: &str) -> Result<&'static str> {
|
||||
SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}")))
|
||||
}
|
||||
|
||||
fn ident_ok(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn from_repack(root: &Path, account: Account) -> Result<Db> {
|
||||
let creds: Credentials = fsx::read_json(&root.join("Settings/database.json"))
|
||||
.map_err(|_| Error::Invalid("The database settings of this server could not be read.".into()))?;
|
||||
let ports: Ports = read_ports(root);
|
||||
let bin = root.join("mysql/bin");
|
||||
if !bin.join("mysqldump.exe").is_file() || !bin.join("mysql.exe").is_file() {
|
||||
return Err(Error::Invalid("The bundled database tools are missing.".into()));
|
||||
}
|
||||
let (user, password) = match account {
|
||||
Account::Admin => ("root", creds.root_password),
|
||||
Account::App => ("acore", creds.app_password),
|
||||
};
|
||||
Ok(Db { bin, port: ports.mysql, user, password: Secret(password) })
|
||||
}
|
||||
|
||||
fn command(&self, tool: &str) -> Command {
|
||||
let mut c = Command::new(self.bin.join(tool));
|
||||
c.env("MYSQL_PWD", self.password.expose())
|
||||
.arg("--protocol=tcp")
|
||||
.arg("--host=127.0.0.1")
|
||||
.arg(format!("--port={}", self.port))
|
||||
.arg(format!("--user={}", self.user))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
no_window(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
fn fail(&self, stderr: &[u8]) -> Error {
|
||||
let text = String::from_utf8_lossy(stderr);
|
||||
let code = if text.contains("2003") || text.contains("Can't connect") { ErrorCode::DatabaseNotRunning } else { ErrorCode::Unknown };
|
||||
match code {
|
||||
ErrorCode::DatabaseNotRunning => Error::Invalid("The database is not running. Start the server first.".into()),
|
||||
_ => Error::Invalid(format!("database command failed: {}", text.trim().lines().last().unwrap_or(""))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ping(&self) -> bool {
|
||||
let mut c = self.command("mysqladmin.exe");
|
||||
c.arg("ping").arg("--connect-timeout=3");
|
||||
c.output().map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Run SQL text (statement(s) supplied on stdin) and return the raw output.
|
||||
pub fn query(&self, sql: &str) -> Result<String> {
|
||||
let mut c = self.command("mysql.exe");
|
||||
c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]);
|
||||
c.stdin(Stdio::piped());
|
||||
let mut child = c.spawn()?;
|
||||
child.stdin.take().expect("piped").write_all(sql.as_bytes())?;
|
||||
let out = child.wait_with_output()?;
|
||||
if !out.status.success() {
|
||||
return Err(self.fail(&out.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub fn schema_exists(&self, name: &str) -> Result<bool> {
|
||||
if !ident_ok(name) {
|
||||
return Err(Error::Invalid("bad schema name".into()));
|
||||
}
|
||||
Ok(self.query(&format!("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='{name}';"))? == "1")
|
||||
}
|
||||
|
||||
pub fn tables(&self, schema: &str) -> Result<Vec<String>> {
|
||||
if !ident_ok(schema) {
|
||||
return Err(Error::Invalid("bad schema name".into()));
|
||||
}
|
||||
let out = self.query(&format!("SELECT table_name FROM information_schema.tables WHERE table_schema='{schema}' AND table_type='BASE TABLE' ORDER BY table_name;"))?;
|
||||
Ok(out.lines().map(str::to_string).filter(|s| !s.is_empty()).collect())
|
||||
}
|
||||
|
||||
/// Bytes of data + index the schema occupies (used to estimate backup size).
|
||||
pub fn schema_bytes(&self, schema: &str) -> Result<u64> {
|
||||
if !ident_ok(schema) {
|
||||
return Err(Error::Invalid("bad schema name".into()));
|
||||
}
|
||||
let out = self.query(&format!("SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema='{schema}';"))?;
|
||||
out.trim().parse().map_err(|_| Error::Invalid("unexpected size reply".into()))
|
||||
}
|
||||
|
||||
/// Stored routines / triggers / views make a table-level swap unsafe; count them.
|
||||
pub fn extra_objects(&self, schema: &str) -> Result<u64> {
|
||||
if !ident_ok(schema) {
|
||||
return Err(Error::Invalid("bad schema name".into()));
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema='{schema}') + (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='{schema}') + (SELECT COUNT(*) FROM information_schema.views WHERE table_schema='{schema}');"
|
||||
);
|
||||
self.query(&sql)?.trim().parse().map_err(|_| Error::Invalid("unexpected reply".into()))
|
||||
}
|
||||
|
||||
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
|
||||
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
|
||||
if !ident_ok(schema) {
|
||||
return Err(Error::Invalid("bad schema name".into()));
|
||||
}
|
||||
let mut c = self.command("mysqldump.exe");
|
||||
c.args(["--single-transaction", "--quick", "--routines", "--triggers", "--default-character-set=utf8mb4", "--no-tablespaces", "--skip-comments", "--hex-blob"]);
|
||||
c.arg(schema);
|
||||
let mut child = c.spawn()?;
|
||||
let mut stdout = child.stdout.take().expect("piped");
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
let err_thread = std::thread::spawn(move || {
|
||||
let mut b = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut b);
|
||||
b
|
||||
});
|
||||
|
||||
let file = File::create(out)?;
|
||||
let mut enc = zstd::Encoder::new(BufWriter::new(file), 3)?;
|
||||
let copy = std::io::copy(&mut stdout, &mut enc);
|
||||
let finish = enc.finish().and_then(|mut w| w.flush());
|
||||
let status = child.wait()?;
|
||||
let stderr_bytes = err_thread.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(out);
|
||||
return Err(self.fail(&stderr_bytes));
|
||||
}
|
||||
copy?;
|
||||
finish?;
|
||||
let size = std::fs::metadata(out)?.len();
|
||||
Ok((size, fsx::sha256_file(out)?))
|
||||
}
|
||||
|
||||
/// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty).
|
||||
pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> {
|
||||
if !ident_ok(schema) {
|
||||
return Err(Error::Invalid("bad schema name".into()));
|
||||
}
|
||||
let mut c = self.command("mysql.exe");
|
||||
c.args(["--default-character-set=utf8mb4", "--max-allowed-packet=128M"]);
|
||||
c.arg(schema);
|
||||
c.stdin(Stdio::piped());
|
||||
let mut child = c.spawn()?;
|
||||
let mut stdin = child.stdin.take().expect("piped");
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
let err_thread = std::thread::spawn(move || {
|
||||
let mut b = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut b);
|
||||
b
|
||||
});
|
||||
let mut dec = zstd::Decoder::new(BufReader::new(File::open(dump)?))?;
|
||||
let copied = std::io::copy(&mut dec, &mut stdin);
|
||||
drop(stdin);
|
||||
let status = child.wait()?;
|
||||
let stderr_bytes = err_thread.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
return Err(self.fail(&stderr_bytes));
|
||||
}
|
||||
copied?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn valid_identifier(name: &str) -> bool {
|
||||
ident_ok(name)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use crate::fsx;
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Verb {
|
||||
StartAll,
|
||||
StartMysql,
|
||||
StopAll,
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ impl Verb {
|
||||
fn arg(self) -> &'static str {
|
||||
match self {
|
||||
Verb::StartAll => "start-all",
|
||||
Verb::StartMysql => "start-mysql",
|
||||
Verb::StopAll => "stop-all",
|
||||
}
|
||||
}
|
||||
@@ -30,6 +32,7 @@ impl Verb {
|
||||
fn timeout(self) -> Duration {
|
||||
match self {
|
||||
Verb::StartAll => Duration::from_secs(420),
|
||||
Verb::StartMysql => Duration::from_secs(120),
|
||||
Verb::StopAll => Duration::from_secs(240),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod driver;
|
||||
pub mod error;
|
||||
pub mod fsx;
|
||||
|
||||
+84
-2
@@ -5,6 +5,7 @@ use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use coa_core::backup::{self, Kind, RecoveryPoint, Trigger, VerifyReport};
|
||||
use coa_core::config::{self, Scope, SettingsView};
|
||||
use coa_core::driver::{self, DriverOutcome, Verb};
|
||||
use coa_core::error::UiError;
|
||||
@@ -15,7 +16,7 @@ use coa_core::{Error, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use tauri::State;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
struct AppState {
|
||||
registry: Registry,
|
||||
@@ -236,6 +237,81 @@ async fn restore_config_snapshot(state: State<'_, AppState>, id: String, snapsho
|
||||
blocking(move || config::restore_snapshot(&meta_dir(&root)?, &snapshot)).await
|
||||
}
|
||||
|
||||
/// Marks the installation busy for the duration of a long operation, and always clears it.
|
||||
struct BusyGuard<'a> {
|
||||
state: &'a AppState,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl<'a> BusyGuard<'a> {
|
||||
fn acquire(state: &'a AppState, id: &str) -> Result<Self> {
|
||||
let mut b = state.busy.lock().map_err(|_| Error::Invalid("state poisoned".into()))?;
|
||||
if !b.insert(id.to_string()) {
|
||||
return Err(Error::Invalid("Another action is still in progress for this server.".into()));
|
||||
}
|
||||
Ok(Self { state, id: id.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BusyGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut b) = self.state.busy.lock() {
|
||||
b.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_backups(state: State<'_, AppState>, id: String) -> std::result::Result<Vec<RecoveryPoint>, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
Ok(backup::list(&meta_dir(&root)?))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_backup(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
kind: Kind,
|
||||
label: Option<String>,
|
||||
) -> std::result::Result<RecoveryPoint, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || {
|
||||
let meta = meta_dir(&root)?;
|
||||
backup::create(&root, &meta, kind, Trigger::Manual, label, &|step| {
|
||||
let _ = app.emit("backup-progress", step);
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn verify_backup(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<VerifyReport, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
blocking(move || backup::verify(&meta_dir(&root)?, &backup_id)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_backup(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<(), UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
Ok(backup::delete(&meta_dir(&root)?, &backup_id)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_backup_configs(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<RecoveryPoint, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || backup::restore_configs(&root, &meta_dir(&root)?, &backup_id)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_backup_database(state: State<'_, AppState>, id: String, backup_id: String, database: String) -> std::result::Result<backup::DbRestore, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || backup::restore_database(&root, &meta_dir(&root)?, &backup_id, &database)).await
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let dir = data_dir();
|
||||
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
|
||||
@@ -256,7 +332,13 @@ pub fn run() {
|
||||
list_presets,
|
||||
preview_preset,
|
||||
list_config_snapshots,
|
||||
restore_config_snapshot
|
||||
restore_config_snapshot,
|
||||
list_backups,
|
||||
create_backup,
|
||||
verify_backup,
|
||||
delete_backup,
|
||||
restore_backup_configs,
|
||||
restore_backup_database
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CoA Server Manager");
|
||||
|
||||
@@ -143,6 +143,24 @@ export interface SaveReport {
|
||||
snapshot: string | null;
|
||||
}
|
||||
|
||||
export type BackupKind = "quick" | "full" | "config" | "database";
|
||||
|
||||
export interface RecoveryPoint {
|
||||
schema: number;
|
||||
id: string;
|
||||
kind: BackupKind;
|
||||
trigger: string;
|
||||
label: string | null;
|
||||
created_at: string;
|
||||
components: { name: string; path: string; bytes: number; tables: number | null; files: string[] | null }[];
|
||||
}
|
||||
|
||||
export interface DbRestore {
|
||||
previous_schema: string;
|
||||
safety_backup: string;
|
||||
tables_restored: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
||||
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
|
||||
@@ -155,6 +173,12 @@ export const api = {
|
||||
settings: (id: string, scope: Scope) => invoke<SettingsView>("get_settings", { id, scope }),
|
||||
save: (id: string, scope: Scope, changes: Record<string, JsonValue>) => invoke<SaveReport>("save_settings", { id, scope, changes }),
|
||||
presets: (scope: Scope) => invoke<PresetInfo[]>("list_presets", { scope }),
|
||||
backups: (id: string) => invoke<RecoveryPoint[]>("list_backups", { id }),
|
||||
createBackup: (id: string, kind: BackupKind, label?: string) => invoke<RecoveryPoint>("create_backup", { id, kind, label: label ?? null }),
|
||||
verifyBackup: (id: string, backupId: string) => invoke<{ ok: boolean; problems: string[] }>("verify_backup", { id, backupId }),
|
||||
deleteBackup: (id: string, backupId: string) => invoke<void>("delete_backup", { id, backupId }),
|
||||
restoreConfigs: (id: string, backupId: string) => invoke<RecoveryPoint>("restore_backup_configs", { id, backupId }),
|
||||
restoreDatabase: (id: string, backupId: string, database: string) => invoke<DbRestore>("restore_backup_database", { id, backupId, database }),
|
||||
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Loader2, ShieldCheck } from "lucide-react";
|
||||
import { api, asUiError, type BackupKind, type RecoveryPoint, type UiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
const TRIGGER: Record<string, string> = {
|
||||
manual: "Manual backup",
|
||||
automatic: "Automatic backup",
|
||||
"before-update": "Before update",
|
||||
"before-restore": "Safety copy before a restore",
|
||||
"before-migration": "Before database update",
|
||||
"before-bots": "Before installing companions",
|
||||
"before-repair": "Before repair",
|
||||
"before-dangerous-change": "Before a risky change",
|
||||
};
|
||||
|
||||
const KIND: Record<BackupKind, string> = {
|
||||
quick: "Characters, accounts and settings",
|
||||
full: "Everything, including the world database",
|
||||
config: "Settings only",
|
||||
database: "Characters and accounts",
|
||||
};
|
||||
|
||||
const DB_LABEL: Record<string, string> = { characters: "Characters", auth: "Accounts", world: "World data" };
|
||||
|
||||
function size(bytes: number): string {
|
||||
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)} GB`;
|
||||
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
function when(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (d.toDateString() === today.toDateString()) return `Today ${time}`;
|
||||
const y = new Date(today.getTime() - 86400000);
|
||||
if (d.toDateString() === y.toDateString()) return `Yesterday ${time}`;
|
||||
return `${d.toLocaleDateString([], { day: "numeric", month: "short", year: "numeric" })} ${time}`;
|
||||
}
|
||||
|
||||
export function BackupsPage({ serverId }: { serverId: string }) {
|
||||
const [points, setPoints] = useState<RecoveryPoint[] | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<string | null>(null);
|
||||
const [error, setError] = useState<UiError | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [restoring, setRestoring] = useState<RecoveryPoint | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPoints(await api.backups(serverId));
|
||||
const s = await api.status(serverId);
|
||||
setRunning(s.observed.world.state !== "stopped" || s.observed.auth.state !== "stopped");
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch((e) => setError(asUiError(e)));
|
||||
const un = listen<string>("backup-progress", (e) => setStep(e.payload));
|
||||
return () => {
|
||||
void un.then((f) => f());
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
async function run(label: string, fn: () => Promise<string | void>) {
|
||||
setBusy(label);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setStep(null);
|
||||
try {
|
||||
const msg = await fn();
|
||||
if (msg) setNotice(msg);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(asUiError(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setStep(null);
|
||||
}
|
||||
}
|
||||
|
||||
const create = (kind: BackupKind) =>
|
||||
run("create", async () => {
|
||||
await api.createBackup(serverId, kind);
|
||||
return "Backup created.";
|
||||
});
|
||||
|
||||
const last = points?.[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-2xl font-semibold">Backups</h1>
|
||||
<p className="mt-1 text-muted">Is my data safe?</p>
|
||||
|
||||
<Card className="mt-6 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className={last ? "h-6 w-6 text-ok" : "h-6 w-6 text-muted"} aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">{last ? `Last backup: ${when(last.created_at)}` : "No backups yet"}</p>
|
||||
<p className="text-sm text-muted">Backups are kept next to your server and never overwrite your live data.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary" disabled={!!busy} onClick={() => void create("quick")}>
|
||||
{busy === "create" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
Back up now
|
||||
</Button>
|
||||
<Button disabled={!!busy} onClick={() => void create("full")} title="Also saves the large world database">
|
||||
Full backup
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={!!busy} onClick={() => void create("config")}>
|
||||
Settings only
|
||||
</Button>
|
||||
{busy === "create" && <span className="text-sm text-muted" role="status">{step ?? "Working…"}</span>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{notice && <p className="mt-3 text-sm text-ok" role="status">{notice}</p>}
|
||||
{error && (
|
||||
<Card className="mt-4 border-bad/40 p-4" role="alert">
|
||||
<p className="font-medium text-bad">{error.human.code === "unknown" ? "That did not work" : error.human.title}</p>
|
||||
<p className="mt-1 text-sm text-muted">{error.human.code === "unknown" ? error.technical : error.human.message}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{restoring && (
|
||||
<Card className="mt-4 border-gold/40 p-5">
|
||||
<p className="font-medium">Restore from {TRIGGER[restoring.trigger] ?? restoring.trigger}, {when(restoring.created_at)}</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Nothing is deleted. Your current data is saved first, and a replaced database is kept alongside under a new name.
|
||||
</p>
|
||||
{running && <p className="mt-2 text-sm text-warn">Stop the server first — databases can only be restored while it is stopped.</p>}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{restoring.components.map((c) =>
|
||||
c.name === "configs" ? (
|
||||
<Button
|
||||
key={c.name}
|
||||
size="sm"
|
||||
disabled={!!busy}
|
||||
onClick={() =>
|
||||
void run("restore", async () => {
|
||||
await api.restoreConfigs(serverId, restoring.id);
|
||||
setRestoring(null);
|
||||
return "Settings restored. A safety copy of the previous settings was saved.";
|
||||
})
|
||||
}
|
||||
>
|
||||
Restore settings
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={c.name}
|
||||
size="sm"
|
||||
disabled={!!busy || running}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Restore the ${DB_LABEL[c.name] ?? c.name} database from this backup? Progress made since ${when(restoring.created_at)} will be set aside (not deleted).`)) return;
|
||||
void run("restore", async () => {
|
||||
const r = await api.restoreDatabase(serverId, restoring.id, c.name);
|
||||
setRestoring(null);
|
||||
return `Restored ${r.tables_restored} tables. The previous data is kept as ${r.previous_schema}.`;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Restore {DB_LABEL[c.name]?.toLowerCase() ?? c.name}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => setRestoring(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mt-6 divide-y divide-line rounded-card border border-line bg-card/60">
|
||||
{points === null && <p className="p-4 text-muted">Loading…</p>}
|
||||
{points?.length === 0 && <p className="p-4 text-muted">Your backups will appear here.</p>}
|
||||
{points?.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-4 p-4">
|
||||
<div>
|
||||
<p className="font-medium">{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : (TRIGGER[p.trigger] ?? p.trigger)}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{when(p.created_at)} · {KIND[p.kind]} · {size(p.components.reduce((a, c) => a + c.bytes, 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="sm" disabled={!!busy} onClick={() => setRestoring(p)}>
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!!busy}
|
||||
onClick={() =>
|
||||
void run("verify", async () => {
|
||||
const v = await api.verifyBackup(serverId, p.id);
|
||||
return v.ok ? "This backup is intact." : `Problem found: ${v.problems.join("; ")}`;
|
||||
})
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!!busy}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this backup? Only this backup is removed; your server is not affected.")) {
|
||||
void run("delete", async () => {
|
||||
await api.deleteBackup(serverId, p.id);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Overview } from "@/screens/Overview";
|
||||
import { Placeholder } from "@/screens/Placeholder";
|
||||
import { SettingsPage } from "@/screens/SettingsPage";
|
||||
import { BackupsPage } from "@/screens/BackupsPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings";
|
||||
@@ -79,6 +80,8 @@ export function Shell(props: {
|
||||
<Overview key={server.id} server={server} onForget={() => props.onForget(server.id)} />
|
||||
) : page === "bots" || page === "server" ? (
|
||||
<SettingsPage key={`${server.id}-${page}`} serverId={server.id} scope={page} title={current.label} question={current.question} />
|
||||
) : page === "backups" ? (
|
||||
<BackupsPage key={server.id} serverId={server.id} />
|
||||
) : (
|
||||
<Placeholder title={current.label} question={current.question} />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user