From 02843bb6d16bf488fabcebe128f0f0b19f7fae86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:22:14 +0500 Subject: [PATCH] Check release compatibility and recover unhealthy updates automatically --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261010-automatic-update-recovery.md | 7 ++ changelog.d/20261010-release-compatibility.md | 7 ++ crates/coa-core/src/manifest.rs | 40 +++++++++ crates/coa-core/src/package.rs | 2 + crates/coa-core/src/update.rs | 85 ++++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- src-tauri/src/lib.rs | 4 +- src-tauri/tauri.conf.json | 2 +- 11 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 changelog.d/20261010-automatic-update-recovery.md create mode 100644 changelog.d/20261010-release-compatibility.md diff --git a/Cargo.lock b/Cargo.lock index d19496c..85b38d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.12" +version = "0.6.13" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.12" +version = "0.6.13" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.12" +version = "0.6.13" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 36f32b9..c012fd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.6.12" +version = "0.6.13" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261010-automatic-update-recovery.md b/changelog.d/20261010-automatic-update-recovery.md new file mode 100644 index 0000000..0c3b056 --- /dev/null +++ b/changelog.d/20261010-automatic-update-recovery.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Failed server startup after an update automatically restores the previous version +--- +The Manager restores server files and databases, then checks the recovered server. If recovery cannot be confirmed, it preserves the recovery point and blocks further startup until the problem is resolved. diff --git a/changelog.d/20261010-release-compatibility.md b/changelog.d/20261010-release-compatibility.md new file mode 100644 index 0000000..f339e9a --- /dev/null +++ b/changelog.d/20261010-release-compatibility.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Server updates can require a tested combination of platform and installed version +--- +Signed release metadata identifies the core and module revisions and supported starting versions. Incompatible update packages are rejected before the server is stopped. diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index b87dbe9..9700f02 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -78,9 +78,22 @@ pub struct Revision { pub commit: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Compatibility { + pub schema: u32, + pub platform: String, + pub core_commit: String, + pub module_commits: std::collections::BTreeMap, + pub client_patch_version: String, + pub source_versions: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Manifest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compatibility: Option, pub schema: u32, pub kind: Kind, pub version: String, @@ -136,6 +149,18 @@ impl Manifest { if parse_version(&self.min_manager_version).is_none() { return bad(format!("bad minManagerVersion {:?}", self.min_manager_version)); } + if let Some(c) = &self.compatibility { + if parse_version(&self.min_manager_version).is_none_or(|v| v < (0, 6, 13)) { + return bad("Release compatibility matrices require Manager 0.6.13 or newer".into()); + } + if c.schema != 1 || !matches!(c.platform.as_str(), "windows-x86_64" | "linux-x86_64") + || !is_commit(&c.core_commit) || self.core.commit.as_deref() != Some(&c.core_commit) + || c.module_commits.is_empty() || c.module_commits.values().any(|sha| !is_commit(sha)) + || parse_version(&c.client_patch_version).is_none() + || c.source_versions.is_empty() || c.source_versions.iter().any(|v| parse_version(v).is_none()) { + return bad("Invalid release compatibility matrix".into()); + } + } for migration in &self.migrations { if !is_sha256(&migration.sha256) || migration.compatible_sha256.iter().any(|h| !is_sha256(h) || h == &"0".repeat(64)) { return bad(format!("{}: invalid migration checksum compatibility", migration.id)); @@ -232,6 +257,21 @@ mod tests { assert!(!m.compatible_with_manager("0.0.9")); } + #[test] + fn compatibility_matrix_binds_core_and_requires_tested_sources() { + let mut m = Manifest::parse(sample("").as_bytes()).unwrap(); + m.min_manager_version = "0.6.13".into(); + m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(), + core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), + client_patch_version: "1.5.1".into(), source_versions: vec!["0.261010.32".into()] }); + m.validate().unwrap(); + m.compatibility.as_mut().unwrap().core_commit = "c".repeat(40); + assert!(m.validate().is_err()); + m.compatibility.as_mut().unwrap().core_commit = "a".repeat(40); + m.compatibility.as_mut().unwrap().source_versions.clear(); + assert!(m.validate().is_err()); + } + #[test] fn rejects_traversal_bad_hash_duplicates_and_short_commit() { let ok = "b".repeat(64); diff --git a/crates/coa-core/src/package.rs b/crates/coa-core/src/package.rs index 26e54e8..50dafe6 100644 --- a/crates/coa-core/src/package.rs +++ b/crates/coa-core/src/package.rs @@ -217,6 +217,7 @@ pub fn build(src: &Path, out: &Path, opts: &BuildOptions, progress: &dyn Fn(&str split.close_current()?; let manifest = Manifest { + compatibility: None, schema: crate::manifest::SCHEMA, kind: opts.kind, version: opts.version.clone(), @@ -503,6 +504,7 @@ mod tests { fs::write(d.path().join(name), &bytes).unwrap(); let ok_sha = fsx::sha256_bytes(b"data"); let m = Manifest { + compatibility: None, schema: 1, kind: Kind::Base, version: "0.1.0".into(), diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index c90711f..2b415c4 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -85,6 +85,8 @@ fn legacy_databases_started() -> bool { true } /// Everything the transaction needs from the outside world; tests provide a fake. pub trait Env { + fn automatic_rollback(&self) -> bool { false } + fn validate_recovery(&self) -> Result<()> { self.validate() } fn ensure_stopped(&self) -> Result<()>; fn preflight(&self, _manifest: &Manifest) -> Result<()> { Ok(()) } fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) } @@ -360,6 +362,15 @@ fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, } fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { + if let Some(c) = &manifest.compatibility { + let platform = if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }; + if c.platform != platform { + return Err(Error::Invalid(format!("This package targets {} rather than {platform}.", c.platform))); + } + if meta.core.version.as_ref().is_none_or(|version| !c.source_versions.contains(version)) { + return Err(Error::Invalid("This server version is outside the release's tested compatibility matrix.".into())); + } + } if let Some(installed) = meta.core.version.as_deref() { if let (Some(have), Some(candidate)) = (crate::manifest::parse_version(installed), crate::manifest::parse_version(&manifest.version)) { if candidate < have { @@ -380,6 +391,11 @@ fn check_manifest(m: &Manifest) -> Result<()> { Ok(()) } +pub fn validate_candidate(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { + check_manifest(manifest)?; + reject_downgrade(meta, manifest) +} + pub struct Params<'a> { pub root: &'a Path, pub meta_dir: &'a Path, @@ -515,6 +531,10 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { step(report, "Starting the updated server", 85); if let Err(e) = p.env.validate() { + if p.env.automatic_rollback() { + return fail_after_apply(p, &mut txn, &before, &tree, + format!("The updated server did not start correctly: {e}"), migrated); + } txn.state = State::NeedsDecision; txn.message = Some(format!("The updated server did not start correctly: {e}")); save(meta_dir, &txn)?; @@ -599,7 +619,10 @@ pub fn retry_validation(root: &Path, meta_dir: &Path, id: &str, fallback: &Sourc fn fail_after_apply(p: &Params, txn: &mut Txn, before: &Path, tree: &Path, why: String, report: Option) -> Result { tracing::error!(transaction = %txn.id, databases_started = txn.databases_started, "server update failed; restoring recovery point"); - let restored = restore_transaction(p.root, p.meta_dir, before, txn, p.env); + let restored = restore_transaction(p.root, p.meta_dir, before, txn, p.env).and_then(|()| { + if p.env.automatic_rollback() { p.env.validate_recovery() } else { Ok(()) } + }); + if restored.is_err() { let _ = p.env.ensure_stopped(); } tracing::info!(transaction = %txn.id, recovered = restored.is_ok(), "server update recovery finished"); txn.state = if restored.is_ok() { State::RolledBack } else { State::Failed }; txn.message = Some(match &restored { Ok(()) => why.clone(), Err(e) => format!("{why} Recovery failed: {e}") }); @@ -839,6 +862,7 @@ impl RepackEnv<'_> { } impl Env for RepackEnv<'_> { + fn automatic_rollback(&self) -> bool { true } fn preflight(&self, manifest: &Manifest) -> Result<()> { self.pending_migrations(manifest).map(|_| ()) } @@ -983,6 +1007,8 @@ mod tests { use std::cell::{Cell, RefCell}; struct Fake { + automatic_rollback: Cell, + recovery_healthy: Cell, stopped: Cell, healthy: Cell, snapshot_ok: Cell, @@ -994,11 +1020,16 @@ mod tests { impl Fake { fn ok() -> Fake { - Fake { stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), preflight_fail: Cell::new(false), restore_fail: Cell::new(false) } + Fake { automatic_rollback: Cell::new(false), recovery_healthy: Cell::new(true), stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), preflight_fail: Cell::new(false), restore_fail: Cell::new(false) } } } impl Env for Fake { + fn automatic_rollback(&self) -> bool { self.automatic_rollback.get() } + fn validate_recovery(&self) -> Result<()> { + self.calls.borrow_mut().push("validate-recovery"); + if self.recovery_healthy.get() { Ok(()) } else { Err(Error::Invalid("Restored server is unhealthy".into())) } + } fn preflight(&self, _: &Manifest) -> Result<()> { if self.preflight_fail.get() { Err(Error::Invalid("Conflicting migration history".into())) } else { Ok(()) } } @@ -1304,6 +1335,56 @@ mod tests { assert!(rollback(&w.root, &w.meta, &out.txn.id, &env).is_err(), "already rolled back"); } + #[test] + fn automatic_health_failure_restores_databases_files_and_checks_recovery() { + let w = world(&[], true); + let env = Fake::ok(); + env.automatic_rollback.set(true); + env.healthy.set(false); + assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("restored")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(!w.root.join("Core/newfile.dll").exists()); + assert!(env.calls.borrow().contains(&"restore-databases")); + assert!(env.calls.borrow().contains(&"validate-recovery")); + assert!(unfinished(&w.meta).is_none()); + } + + #[test] + fn incompatible_source_or_platform_is_rejected_without_touching_installation() { + let w = world(&[], false); + let (_, meta) = MetaDir::open(&w.meta).unwrap(); + let mut manifest = Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); + manifest.min_manager_version = "0.6.13".into(); + manifest.core.commit = Some("a".repeat(40)); + manifest.compatibility = Some(crate::manifest::Compatibility { schema: 1, + platform: if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }.into(), + core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), + client_patch_version: "1.5.1".into(), source_versions: vec!["1.0.0".into()] }); + manifest.validate().unwrap(); + validate_candidate(&meta, &manifest).unwrap(); + manifest.compatibility.as_mut().unwrap().source_versions = vec!["0.9.0".into()]; + assert!(validate_candidate(&meta, &manifest).unwrap_err().to_string().contains("compatibility matrix")); + manifest.compatibility.as_mut().unwrap().source_versions = vec!["1.0.0".into()]; + manifest.compatibility.as_mut().unwrap().platform = if cfg!(windows) { "linux-x86_64" } else { "windows-x86_64" }.into(); + assert!(validate_candidate(&meta, &manifest).unwrap_err().to_string().contains("targets")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(unfinished(&w.meta).is_none()); + } + + #[test] + fn unhealthy_restoration_preserves_recovery_and_blocks_startup() { + let w = world(&[], true); + let env = Fake::ok(); + env.automatic_rollback.set(true); + env.healthy.set(false); + env.recovery_healthy.set(false); + assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("Recovery failed")); + let pending = unfinished(&w.meta).unwrap(); + assert_eq!(pending.state, State::Failed); + assert!(txn_dir(&w.meta, &pending.id).unwrap().join("before").exists()); + assert!(ensure_recovered(&w.meta).is_err()); + } + #[test] fn retrying_startup_commits_without_reapplying_sql_or_restoring_databases() { let w = world(&[], true); diff --git a/package-lock.json b/package-lock.json index 0956d7a..449c0ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.12", + "version": "0.6.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.12", + "version": "0.6.13", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 6a3d5fa..0265fc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.12", + "version": "0.6.13", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1110b03..7dc9d80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -831,7 +831,9 @@ async fn apply_update( let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let src = update_source(source)?; - let (dir, _) = install_meta(&root)?; + let (dir, meta) = install_meta(&root)?; + let (manifest, _) = coa_core::pkgsource::fetch_manifest(&src, coa_core::signing::EMBEDDED_PUBLIC_KEY)?; + update::validate_candidate(&meta, &manifest)?; // Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button. let observed = coa_core::process::observe(&root, &layout::read_ports(&root)); if observed.world.state != coa_core::process::ServiceState::Stopped || observed.auth.state != coa_core::process::ServiceState::Stopped || coa_core::multiworld::is_running(&root) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fb36d12..43df25d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.6.12", + "version": "0.6.13", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev",