mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Harden server update history, recovery guards and repeated checks
This commit is contained in:
1 parent
3940506987
commit
0d6d8a0db8
37 files changed
+1112
-99
No files matched your search
Generated
+3
-3
@@ -491,7 +491,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coa-core"
|
name = "coa-core"
|
||||||
version = "0.6.4"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -519,7 +519,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coa-release"
|
name = "coa-release"
|
||||||
version = "0.6.4"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"coa-core",
|
"coa-core",
|
||||||
@@ -528,7 +528,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coa-server-manager"
|
name = "coa-server-manager"
|
||||||
version = "0.6.4"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"coa-core",
|
"coa-core",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
|
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.6.4"
|
version = "0.6.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Urgent update hotfix
|
||||||
|
|
||||||
|
Deadline: 2026-10-06. This checkout starts at the exact v0.6.4 tag, commit
|
||||||
|
6c771fa, and excludes the ongoing isolated-trial/recovery redesign.
|
||||||
|
|
||||||
|
The client accepts checksum compatibility only from a verified signed manifest and only
|
||||||
|
for an Applied migration. It never updates the historical checksum, reruns historical
|
||||||
|
SQL, or excuses a Running/Failed ledger row. A server package must explicitly list the
|
||||||
|
verified published variants and append corrections under new migration IDs.
|
||||||
|
|
||||||
|
Current local evidence: the locked workspace tests passed, including 252 core tests
|
||||||
|
run serially. Six frontend logic tests and the frontend production build passed.
|
||||||
|
The hotfix runtime was tested against the exact signed candidate manifest on the marked
|
||||||
|
CoA/Wildcard fixture. Both published repair checksums were accepted independently in
|
||||||
|
both worlds, with zero migration executions, unchanged historical checksums, and unchanged
|
||||||
|
account, character and inventory counts. The original ledger rows were restored after
|
||||||
|
the test, and all fixture processes were stopped. A real Running or Failed repair row in
|
||||||
|
either world was rejected before staging or creating a transaction, with unchanged world
|
||||||
|
executable and installation metadata. Tests use independent hotfix metadata so experimental
|
||||||
|
transaction formats are not confused with the 0.6.4 upgrade path. Windows journal writes also use flushed
|
||||||
|
atomic replacement with long-path support. Changelog validation passed.
|
||||||
|
|
||||||
|
Additional guards implemented:
|
||||||
|
|
||||||
|
- Read migration history without creating its ledger before downloading or replacing files.
|
||||||
|
- Reject missing journals, invalid identities, paths, hashes and inconsistent operations.
|
||||||
|
Durable completed-cleanup markers distinguish interrupted pruning from a lost journal.
|
||||||
|
- Preserve all backups while an update is unfinished or its journal is damaged.
|
||||||
|
- Verify saved-file hashes and the complete database/configuration recovery point before
|
||||||
|
stopping services or changing files/databases. New snapshots include configuration hashes;
|
||||||
|
update journals bind their backup metadata to a hash.
|
||||||
|
- Surface state-reading errors in Settings and disable applying until the disk state is
|
||||||
|
successfully read. Checking again refreshes that state.
|
||||||
|
- Verify every extracted SQL artifact before taking the update snapshot or replacing
|
||||||
|
server files; missing files, invalid IDs, duplicate migrations and checksum disagreements
|
||||||
|
reject the update early. SQL application repeats the integrity check immediately before use.
|
||||||
|
- Count unapplied migrations in the active and secondary realms, counting shared auth once.
|
||||||
|
Matching versions and files do not hide remaining database work. Preview reads history
|
||||||
|
under the installation lock; a stopped database is temporarily started for inspection
|
||||||
|
and stopped again when no game services are running.
|
||||||
|
- Reread the disk state after every update/recovery attempt, including failed attempts and
|
||||||
|
background updates. Unfinished or unreadable state blocks another application in the UI.
|
||||||
|
- Acquire the installation lock before checking and deleting a backup.
|
||||||
|
|
||||||
|
Latest real-runtime test used the independently copied, doubly marked fixture
|
||||||
|
`C:\games\coa-schema-fixture-chaos-20261005-v1` and signed candidate `0.261005.24`, manifest SHA-256
|
||||||
|
`1284b1273815209c9621b6d173a2211bae316f33462ccbe8b578e53b8d26fb5b`.
|
||||||
|
All 1748 SQL artifacts passed verification after authenticated archive extraction.
|
||||||
|
Removing one repair record separately in CoA and Wildcard increased the pending count
|
||||||
|
by exactly one; restoring its original record restored the original count. No migrations
|
||||||
|
were executed, player counts were preserved and fixture services were stopped afterward.
|
||||||
|
The source checkout remains uncommitted; these results are not an installer acceptance report.
|
||||||
|
|
||||||
|
Additional acceptance on 2026-10-06: a new independent installation/database copy at
|
||||||
|
`C:\games\coa-schema-fixture-chaos-064-transition-20261006` received the authenticated
|
||||||
|
published `0.261005.22` program files, original CRLF repair checksum, and a deliberately
|
||||||
|
missing Wildcard table in both realms. The production hotfix update pipeline committed
|
||||||
|
signed candidate `0.261005.24`, repaired both realms, passed schema checks and actual
|
||||||
|
bundled-Python server startup, and preserved the selected account, character, item and
|
||||||
|
inventory fields byte-for-byte. The fixture was stopped afterward. The harness explicitly
|
||||||
|
instruments the launcher to prevent relay submissions and bind to localhost; this is not
|
||||||
|
proof of packaged UI behavior or operating-system network containment.
|
||||||
|
|
||||||
|
Local installer `C:\games\source\manager-065-local-installer-20261006\CoA Server Manager_0.6.5_x64-setup.exe`
|
||||||
|
has SHA-256 `520ee0391d923b92da0a20591e30006c048cdb60156275d7a829918bf1b77491`.
|
||||||
|
Its updater signature and the published 0.6.4 installer's signature passed independent
|
||||||
|
verification with the configured updater public key. Both executables extracted from
|
||||||
|
their exact NSIS packages started, created responsive windows, and were stopped using
|
||||||
|
an independent application/WebView profile. No installer was executed over a user's
|
||||||
|
installation, and no UI update operation was exercised.
|
||||||
|
|
||||||
|
Legacy limitation: old 0.6.4 transaction copies and configuration snapshots without recorded
|
||||||
|
hashes can be checked for presence and declared database hashes, but their original file
|
||||||
|
contents cannot be authenticated retroactively. Missing copies block restoration. This
|
||||||
|
hotfix does not claim that old unchecksummed copies received full integrity verification.
|
||||||
|
|
||||||
|
Remaining release requirements: reproduce the existing-user failures through the published
|
||||||
|
0.6.4 interface, verify legacy unfinished transaction handling on real-runtime fixtures,
|
||||||
|
and execute the exact installer over 0.6.4 in an isolated Windows environment with UI
|
||||||
|
state/settings/server-list verification. Windows native UI automation is unavailable in
|
||||||
|
this session. Packaged startup and signature checks do not satisfy that installer gate.
|
||||||
|
No public release has been made.
|
||||||
|
|
||||||
|
The user subsequently exercised the packaged UI: candidate 0.261005.24 committed and
|
||||||
|
both CoA and Wildcard were shown running. Repeat-check/restart acceptance is still pending.
|
||||||
|
The screenshots exposed an older published package being offered after the local candidate
|
||||||
|
was installed. Follow-up changes reject older packages before preview/database inspection
|
||||||
|
and application and clear a cached offer after a failed recheck. All 253 core tests and
|
||||||
|
seven frontend tests passed. The earlier installer hash above does not include this follow-up;
|
||||||
|
a separately recorded revision is required. Native UI control was subsequently initialized
|
||||||
|
through the deferred node_repl tool, then the user stopped automation with Escape and
|
||||||
|
performed the UI check manually.
|
||||||
|
|
||||||
|
Another user repeat-check exposed the already installed launcher being offered again.
|
||||||
|
The preview now verifies canonical launcher bytes against the current signed package,
|
||||||
|
caches those bytes by SHA-256, and compares only recognized Manager transformations.
|
||||||
|
The actual disposable fixture's repeat preview passed with all 104 files skipped and
|
||||||
|
zero pending SQL. User edits, recorded custom content, and a package with different
|
||||||
|
launcher bytes are not mistaken for that integration. All 254 core tests passed;
|
||||||
|
the strengthened regression test and seven frontend tests also passed. The first
|
||||||
|
preview of a managed launcher may download/extract its authenticated package to build
|
||||||
|
the optional canonical cache. Earlier installer revisions do not include this fix.
|
||||||
|
|
||||||
|
Do not recommend deleting an installation or replacing its player databases.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Server updates accept verified published migration variants without rerunning them
|
||||||
|
---
|
||||||
|
Signed updates can recognise known published checksums without changing your migration history. Interrupted or failed SQL still requires recovery before retrying.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Older server packages cannot replace a newer installation
|
||||||
|
---
|
||||||
|
Update checks stop offering stale packages after an update. To undo an update, use its recovery point instead of applying an older server package.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Checking again no longer offers an already installed launcher update
|
||||||
|
---
|
||||||
|
The Manager recognizes its own launcher integration while still detecting user edits and changes in newer signed packages.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Update history conflicts are detected before server files change
|
||||||
|
---
|
||||||
|
Damaged update records block changes and remain visible. Recovery checks saved copies before restoring, and unfinished updates protect backups from deletion and cleanup.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Pending database updates remain visible when the server version matches
|
||||||
|
---
|
||||||
|
SQL files are checked before replacing server files. Update and recovery attempts refresh their saved status, including errors. Backup deletion cannot race with an update.
|
||||||
@@ -31,7 +31,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
|
|||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] }
|
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
testkit = []
|
testkit = []
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Verify published checksum compatibility on a disposable real database, without running SQL migrations.
|
||||||
|
use std::{path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}};
|
||||||
|
use coa_core::{backup, db::Db, migrations::{self, LedgerRow, Status, Store}, pkgsource::{self, Source}, Result};
|
||||||
|
|
||||||
|
struct Observed<'a> { db: &'a Db, executions: AtomicUsize }
|
||||||
|
impl Store for Observed<'_> {
|
||||||
|
fn load(&self) -> Result<Vec<LedgerRow>> { self.db.load() }
|
||||||
|
fn put(&self, row: &LedgerRow) -> Result<()> { self.db.put(row) }
|
||||||
|
fn run_file(&self, _: &str, _: &Path) -> Result<()> {
|
||||||
|
self.executions.fetch_add(1, Ordering::SeqCst);
|
||||||
|
panic!("Historical SQL must never be replayed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||||
|
let root = PathBuf::from(&args[0]);
|
||||||
|
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-"));
|
||||||
|
assert_eq!(std::fs::read(root.join(".coa-chaos-fixture"))?, b"disposable update chaos fixture");
|
||||||
|
assert_eq!(std::fs::read(root.join(".release-schema-fixture"))?, b"disposable release-schema fixture");
|
||||||
|
let source = Source::Dir(PathBuf::from(&args[1]));
|
||||||
|
let package = pkgsource::fetch_manifest(&source, coa_core::signing::EMBEDDED_PUBLIC_KEY)?.0;
|
||||||
|
let staging = tempfile::tempdir()?;
|
||||||
|
let parts = pkgsource::fetch_parts(&source, &package, &staging.path().join("download"), &Default::default(), &|_, _| {})?;
|
||||||
|
coa_core::package::extract(&parts, &package, &staging.path().join("tree"), &|_, _| {})?;
|
||||||
|
migrations::verify_files(&package.migrations, &staging.path().join("tree/_migrations"))?;
|
||||||
|
println!("PASS: all {} signed-package SQL artifacts verified before any database mutation", package.migrations.len());
|
||||||
|
let migration = package.migrations.iter().find(|m| m.id == "manager_repair__20261005_missing_wildcard_tables").unwrap();
|
||||||
|
let hashes = ["c1d8dbf2271234283a48a39e4b4aea1106b83b70581f1e3c4aa9fff438e8e4d2", "9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638"];
|
||||||
|
for hash in hashes { assert!(migration.sha256 == hash || migration.compatible_sha256.iter().any(|h| h == hash)); }
|
||||||
|
let fixture_meta = coa_core::registry::metadata_dir_for(&root)?;
|
||||||
|
let test_meta = tempfile::tempdir()?;
|
||||||
|
coa_core::fsx::atomic_write(&test_meta.path().join("install.json"), &std::fs::read(fixture_meta.join("install.json"))?)?;
|
||||||
|
coa_core::registry::MetaDir::open(test_meta.path())?;
|
||||||
|
std::fs::create_dir_all(test_meta.path().join("updates"))?;
|
||||||
|
let result = backup::with_database(&root, |db| {
|
||||||
|
let mut modes = vec![coa_core::realms::Mode::Coa];
|
||||||
|
if coa_core::realms::state(&root)?.wildcard_created { modes.push(coa_core::realms::Mode::Wildcard); }
|
||||||
|
for mode in modes {
|
||||||
|
let db = db.clone().for_realm(mode);
|
||||||
|
let saved = db.load()?.into_iter().find(|row| row.id == migration.id && row.db == migration.db).expect("Published repair must already be applied");
|
||||||
|
assert_eq!(saved.status, Status::Applied);
|
||||||
|
let counts = db.query("SELECT CONCAT((SELECT COUNT(*) FROM acore_characters.characters),':',(SELECT COUNT(*) FROM acore_characters.item_instance),':',(SELECT COUNT(*) FROM acore_auth.account));")?;
|
||||||
|
let check = || -> Result<()> {
|
||||||
|
for hash in hashes {
|
||||||
|
db.put(&LedgerRow { sha256: hash.into(), ..saved.clone() })?;
|
||||||
|
let observed = Observed { db: &db, executions: AtomicUsize::new(0) };
|
||||||
|
migrations::preflight(&db, std::slice::from_ref(migration))?;
|
||||||
|
let report = migrations::apply_pending(&observed, std::slice::from_ref(migration), &root.join(".unused-hotfix-sql"), &|| panic!("Already applied SQL needs no snapshot"))?;
|
||||||
|
assert!(report.applied.is_empty());
|
||||||
|
assert_eq!(observed.executions.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(db.load()?.into_iter().find(|r| r.id == migration.id && r.db == migration.db).unwrap().sha256, hash);
|
||||||
|
}
|
||||||
|
let meta = test_meta.path();
|
||||||
|
let file_hash = coa_core::fsx::sha256_file(&root.join("Core/worldserver.exe"))?;
|
||||||
|
let install_hash = coa_core::fsx::sha256_file(&meta.join("install.json"))?;
|
||||||
|
let transaction_count = std::fs::read_dir(meta.join("updates"))?.count();
|
||||||
|
for status in [Status::Running, Status::Failed] {
|
||||||
|
db.put(&LedgerRow { status, ..saved.clone() })?;
|
||||||
|
let env = coa_core::update::RepackEnv { root: &root, meta_dir: meta };
|
||||||
|
let rejected = coa_core::update::apply(&coa_core::update::Params {
|
||||||
|
root: &root, meta_dir: meta, source: Source::Dir(PathBuf::from(&args[1])),
|
||||||
|
trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, cancel: Default::default(),
|
||||||
|
resolutions: Default::default(), env: &env, fail_after_ops: None,
|
||||||
|
}, &|_, _| {});
|
||||||
|
let message = rejected.unwrap_err().to_string();
|
||||||
|
assert!(message.contains("partially applied"), "Unexpected rejection: {message}");
|
||||||
|
assert_eq!(std::fs::read_dir(meta.join("updates"))?.count(), transaction_count);
|
||||||
|
assert_eq!(coa_core::fsx::sha256_file(&root.join("Core/worldserver.exe"))?, file_hash);
|
||||||
|
assert_eq!(coa_core::fsx::sha256_file(&meta.join("install.json"))?, install_hash);
|
||||||
|
}
|
||||||
|
db.put(&saved)?;
|
||||||
|
let env = coa_core::update::RepackEnv { root: &root, meta_dir: meta };
|
||||||
|
let pending_before = env.pending_migrations(&package)?;
|
||||||
|
db.query("DELETE FROM acore_world.coa_manager_migrations WHERE db='characters' AND id='manager_repair__20261005_missing_wildcard_tables';")?;
|
||||||
|
assert_eq!(env.pending_migrations(&package)?, pending_before + 1,
|
||||||
|
"Missing history in either realm must remain visible even with identical server files");
|
||||||
|
db.put(&saved)?;
|
||||||
|
assert_eq!(env.pending_migrations(&package)?, pending_before);
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(check));
|
||||||
|
db.put(&saved)?;
|
||||||
|
result.map_err(|_| coa_core::Error::Invalid("History regression assertion failed; the injected ledger row was restored.".into()))??;
|
||||||
|
assert_eq!(db.query("SELECT CONCAT((SELECT COUNT(*) FROM acore_characters.characters),':',(SELECT COUNT(*) FROM acore_characters.item_instance),':',(SELECT COUNT(*) FROM acore_auth.account));")?, counts);
|
||||||
|
println!("PASS: {} published checksums; Running/Failed rejected before staging; missing migration counted; zero SQL replays; history and player counts preserved", mode.name());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(coa_core::driver::run(&root, coa_core::driver::Verb::StopAll)?.ok);
|
||||||
|
result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Read-only update preview against the marked disposable acceptance fixture.
|
||||||
|
use std::{collections::BTreeMap,path::PathBuf};
|
||||||
|
fn main()->coa_core::Result<()> {
|
||||||
|
let args:Vec<_>=std::env::args().skip(1).collect();
|
||||||
|
let root=PathBuf::from(&args[0]);
|
||||||
|
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-"));
|
||||||
|
assert_eq!(std::fs::read(root.join(".transition-fixture"))?,b"disposable published-064 transition fixture");
|
||||||
|
let meta_dir=coa_core::registry::metadata_dir_for(&root)?;
|
||||||
|
let (_,meta)=coa_core::registry::MetaDir::open(&meta_dir)?;
|
||||||
|
let preview=coa_core::update::preview(&root,&meta,&coa_core::pkgsource::Source::Dir(args[1].clone().into()),coa_core::signing::EMBEDDED_PUBLIC_KEY,&BTreeMap::new())?;
|
||||||
|
assert_eq!(preview.from_version.as_deref(),Some(preview.to_version.as_str()));
|
||||||
|
assert_eq!(preview.pending_migrations,0);
|
||||||
|
assert!(preview.items.iter().all(|item|item.action==coa_core::update::Action::Skip),"{:?}",preview.items.iter().filter(|item|item.action!=coa_core::update::Action::Skip).collect::<Vec<_>>());
|
||||||
|
println!("PASS: real fixture repeated preview: {} files unchanged, zero pending SQL, no update offered",preview.items.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! Real signed-package transition on an independent, explicitly marked disposable copy.
|
||||||
|
//! The Env wrapper instruments only launcher isolation; migration/backup/update code is production code.
|
||||||
|
use std::{collections::BTreeMap, path::{Path, PathBuf}};
|
||||||
|
use coa_core::{backup, driver, fsx, migrations, pkgsource::{self, Source}, registry::{InstallMeta, MetaDir}, update::{self, Env, RepackEnv}, Result};
|
||||||
|
|
||||||
|
struct Offline<'a>(RepackEnv<'a>);
|
||||||
|
impl Env for Offline<'_> {
|
||||||
|
fn ensure_stopped(&self) -> Result<()> { self.0.ensure_stopped() }
|
||||||
|
fn preflight(&self, m: &coa_core::manifest::Manifest) -> Result<()> { self.0.preflight(m) }
|
||||||
|
fn verify_snapshot(&self, id: &str) -> Result<()> { self.0.verify_snapshot(id) }
|
||||||
|
fn snapshot(&self) -> Result<String> { self.0.snapshot() }
|
||||||
|
fn restore_snapshot(&self, id: &str) -> Result<()> { self.0.restore_snapshot(id) }
|
||||||
|
fn migrate(&self, m: &coa_core::manifest::Manifest, p: &Path) -> Result<migrations::ApplyReport> { self.0.migrate(m,p) }
|
||||||
|
fn validate(&self) -> Result<()> {
|
||||||
|
let path=self.0.root.join("Scripts/manage.py");
|
||||||
|
let source=std::fs::read_to_string(&path)?.replace("\r\n", "\n");
|
||||||
|
// Explicit test instrumentation prevents relay transmissions and binds services locally.
|
||||||
|
let guarded=source.replace("def spawn(name, command, cwd, log):\n", "def spawn(name, command, cwd, log):\n for filename in ('worldserver.conf', 'authserver.conf'):\n path = ROOT / 'Core/configs' / filename\n if path.exists():\n text = path.read_text(encoding='utf-8-sig')\n for key, value in (('BindIP', '\"127.0.0.1\"'), ('Ra.IP', '\"127.0.0.1\"'), ('SOAP.Enabled', '0')):\n text = re.sub(r'(?m)^\\s*' + re.escape(key) + r'\\s*=.*$', '', text)\n text += '\\n' + key + ' = ' + value + '\\n'\n path.write_text(text, encoding='utf-8')\n").replace("def relay_worker():\n", "def relay_worker():\n save_process('relay-ready', os.getpid())\n while not (STATE / 'stop-relay').exists():\n time.sleep(0.5)\n return\n");
|
||||||
|
assert_ne!(source,guarded,"Fixture launcher isolation failed");
|
||||||
|
fsx::atomic_write(&path,guarded.as_bytes())?;
|
||||||
|
self.0.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn players(db: &coa_core::db::Db) -> Result<String> {
|
||||||
|
db.query("SELECT CONCAT(id,':',username,':',HEX(salt),':',HEX(verifier)) FROM acore_auth.account WHERE id=900001; SELECT CONCAT(guid,':',account,':',name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001; SELECT CONCAT(guid,':',itemEntry,':',owner_guid,':',enchantments) FROM acore_characters.item_instance WHERE guid=900001; SELECT CONCAT(guid,':',bag,':',slot,':',item) FROM acore_characters.character_inventory WHERE guid=900001;")
|
||||||
|
}
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args:Vec<_>=std::env::args().skip(1).collect();
|
||||||
|
let root=PathBuf::from(&args[0]);
|
||||||
|
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-"));
|
||||||
|
assert_eq!(std::fs::read(root.join(".transition-fixture"))?,b"disposable published-064 transition fixture");
|
||||||
|
if args.get(3).is_some_and(|mode| mode=="legacy-recovery") {
|
||||||
|
return run_fixture(&root,||legacy_recovery(&root));
|
||||||
|
}
|
||||||
|
assert!(!root.join(".transition-started").exists(),"Use a new independent fixture");
|
||||||
|
fsx::atomic_write(&root.join(".transition-started"),b"started")?;
|
||||||
|
let meta=coa_core::registry::metadata_dir_for(&root)?;
|
||||||
|
assert!(!meta.exists(),"Do not reuse experimental or user metadata");
|
||||||
|
run_fixture(&root,||exercise(&root,&meta,&args[1],&args[2]))
|
||||||
|
}
|
||||||
|
fn run_fixture(root:&Path,operation:impl FnOnce()->Result<()>)->Result<()> {
|
||||||
|
let result=std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation));
|
||||||
|
assert!(driver::run(root,driver::Verb::StopAll)?.ok,"Fixture shutdown failed");
|
||||||
|
result.map_err(|_|coa_core::Error::Invalid("Disposable acceptance assertion failed; services were stopped and recovery copies retained.".into()))?
|
||||||
|
}
|
||||||
|
fn legacy_recovery(root:&Path)->Result<()> {
|
||||||
|
let meta=coa_core::registry::metadata_dir_for(root)?;
|
||||||
|
assert!(driver::run(root,driver::Verb::StopAll)?.ok);
|
||||||
|
let expected=backup::with_database(root,|db| [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().map(|mode|players(&db.clone().for_realm(mode))).collect::<Result<Vec<_>>>())?;
|
||||||
|
assert!(driver::run(root,driver::Verb::StopAll)?.ok);
|
||||||
|
let dirs=std::fs::read_dir(meta.join("updates"))?.collect::<std::io::Result<Vec<_>>>()?;
|
||||||
|
assert_eq!(dirs.len(),1,"Requires exactly the preceding successful disposable transition");
|
||||||
|
let txn_dir=dirs[0].path();
|
||||||
|
let journal=txn_dir.join("txn.json");
|
||||||
|
let mut legacy:serde_json::Value=fsx::read_json(&journal)?;
|
||||||
|
assert_eq!(legacy["state"],"committed");
|
||||||
|
let point=legacy["recovery_point"].as_str().unwrap().to_string();
|
||||||
|
let point_path=meta.join("backups").join(&point).join("backup.json");
|
||||||
|
let mut point_json:serde_json::Value=fsx::read_json(&point_path)?;
|
||||||
|
point_json["manager_version"]="0.6.4".into();
|
||||||
|
for component in point_json["components"].as_array_mut().unwrap() { component.as_object_mut().unwrap().remove("file_sha256"); }
|
||||||
|
fsx::atomic_write_json(&point_path,&point_json)?;
|
||||||
|
legacy.as_object_mut().unwrap().remove("recovery_hashes");
|
||||||
|
legacy.as_object_mut().unwrap().remove("databases_started");
|
||||||
|
legacy["state"]="applied".into();
|
||||||
|
fsx::atomic_write_json(&journal,&legacy)?;
|
||||||
|
assert!(update::ensure_recovered(&meta).is_err(),"Legacy unfinished update must block startup");
|
||||||
|
let id=legacy["id"].as_str().unwrap();
|
||||||
|
let env=RepackEnv {root,meta_dir:&meta};
|
||||||
|
println!("Recovering legacy-format transaction and recovery point on disposable installation");
|
||||||
|
assert_eq!(update::rollback(root,&meta,id,&env)?.state,update::State::RolledBack);
|
||||||
|
for op in legacy["ops"].as_array().unwrap() {
|
||||||
|
let relative=op["path"].as_str().unwrap();
|
||||||
|
if op["had_previous"]==true {
|
||||||
|
assert_eq!(fsx::sha256_file(&fsx::safe_join(root,relative)?)?,fsx::sha256_file(&fsx::safe_join(&txn_dir.join("before"),relative)?)?,"Legacy file restoration mismatch: {relative}");
|
||||||
|
} else { assert!(!fsx::safe_join(root,relative)?.exists()); }
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read(meta.join("install.json"))?,std::fs::read(txn_dir.join("before/manager-install.json"))?);
|
||||||
|
backup::with_database(root,|db| {
|
||||||
|
for (index,mode) in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().enumerate() {
|
||||||
|
let db=db.clone().for_realm(mode);
|
||||||
|
assert_eq!(players(&db)?,expected[index]);
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM acore_world.coa_manager_migrations WHERE id IN ('manager_repair__20261005_reconcile_wildcard_tables','manager_repair__20261005_reconcile_missing_class_starts');")?,"0");
|
||||||
|
assert_eq!(db.query("SELECT sha256 FROM acore_world.coa_manager_migrations WHERE id='manager_repair__20261005_missing_wildcard_tables';")?,"9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
assert!(driver::run(root,driver::Verb::StopAll)?.ok);
|
||||||
|
assert!(update::pending_checked(&meta)?.is_none());
|
||||||
|
assert!(update::rollback(root,&meta,id,&env).is_err(),"Completed recovery must not run again");
|
||||||
|
println!("PASS: legacy journal blocked startup; old files, metadata, migration histories and player fields restored; completed recovery rejects replay. Original baseline had a missing table, so healthy startup is not claimed after rollback.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn exercise(root:&Path,meta:&Path,baseline:&str,candidate:&str)->Result<()> {
|
||||||
|
let mut install:InstallMeta=fsx::read_json(&PathBuf::from("C:/games/coa-schema-fixture-chaos-20261005-v1.manager/install.json"))?;
|
||||||
|
install.server_path=root.to_string_lossy().into_owned();
|
||||||
|
install.id=uuid::Uuid::new_v4().to_string();
|
||||||
|
install.manager_version="0.6.4".into();
|
||||||
|
MetaDir::create(root,&install)?;
|
||||||
|
// Private ports, and no inherited PID records or paths to the source database.
|
||||||
|
let mut cfg:serde_json::Value=fsx::read_json(&root.join("Settings/repack.json"))?;
|
||||||
|
let mut listeners=Vec::new();
|
||||||
|
for key in ["mysqlPort","authPort","worldPort","raPort"] {
|
||||||
|
let listener=std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
cfg[key]=listener.local_addr()?.port().into(); listeners.push(listener);
|
||||||
|
}
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/repack.json"),&cfg)?;
|
||||||
|
let mut profile:serde_json::Value=fsx::read_json(&root.join("Settings/realm-profile.json"))?;
|
||||||
|
for key in ["secondary_world_port","secondary_ra_port"] {
|
||||||
|
let listener=std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
profile[key]=listener.local_addr()?.port().into(); listeners.push(listener);
|
||||||
|
}
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/realm-profile.json"),&profile)?;
|
||||||
|
drop(listeners);
|
||||||
|
let state=root.join(".state");
|
||||||
|
if state.exists() { std::fs::remove_dir_all(&state)?; }
|
||||||
|
let secondary_state=root.join(".realms/secondary/.state");
|
||||||
|
if secondary_state.exists() { std::fs::remove_dir_all(&secondary_state)?; }
|
||||||
|
let source=Source::Dir(baseline.into());
|
||||||
|
let (manifest,_)=pkgsource::fetch_manifest(&source,coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||||
|
let temp=tempfile::tempdir()?;
|
||||||
|
let parts=pkgsource::fetch_parts(&source,&manifest,&temp.path().join("download"),&Default::default(),&|_,_|{})?;
|
||||||
|
let tree=temp.path().join("tree");
|
||||||
|
coa_core::package::extract(&parts,&manifest,&tree,&|_,_|{})?;
|
||||||
|
for file in &manifest.files {
|
||||||
|
if file.path.starts_with("_migrations/") {continue;}
|
||||||
|
fsx::atomic_write(&fsx::safe_join(root,&file.path)?,&std::fs::read(fsx::safe_join(&tree,&file.path)?)?)?;
|
||||||
|
install.original_hashes.insert(file.path.clone(),file.sha256.clone());
|
||||||
|
}
|
||||||
|
install.core.version=Some(manifest.version);
|
||||||
|
fsx::atomic_write_json(&meta.join("install.json"),&install)?;
|
||||||
|
// Both worlds must remain local, including generated secondary configuration.
|
||||||
|
for relative in ["Settings/worldserver.conf.template","Settings/authserver.conf.template"] {
|
||||||
|
let p=root.join(relative);
|
||||||
|
let mut conf=coa_core::config::parser::ConfFile::parse_bytes(&std::fs::read(&p)?)?;
|
||||||
|
conf.set("BindIP","\"127.0.0.1\"",&[]); conf.set("Ra.IP","\"127.0.0.1\"",&[]); conf.set("SOAP.Enabled","0",&[]);
|
||||||
|
fsx::atomic_write(&p,conf.to_text().as_bytes())?;
|
||||||
|
}
|
||||||
|
let expected=backup::with_database(root,|db| {
|
||||||
|
let mut saved=Vec::new();
|
||||||
|
for mode in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard] {
|
||||||
|
let db=db.clone().for_realm(mode);
|
||||||
|
saved.push(players(&db)?);
|
||||||
|
db.query("DROP TABLE IF EXISTS acore_characters.coa_wildcard_skill_card; DELETE FROM acore_world.coa_manager_migrations WHERE id IN ('manager_repair__20261005_reconcile_wildcard_tables','manager_repair__20261005_reconcile_missing_class_starts'); UPDATE acore_world.coa_manager_migrations SET sha256='9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638' WHERE id='manager_repair__20261005_missing_wildcard_tables' AND status='applied';")?;
|
||||||
|
}
|
||||||
|
Ok(saved)
|
||||||
|
})?;
|
||||||
|
let source=Source::Dir(candidate.into());
|
||||||
|
let candidate_manifest=pkgsource::fetch_manifest(&source,coa_core::signing::EMBEDDED_PUBLIC_KEY)?.0;
|
||||||
|
let choices:BTreeMap<_,_>=update::plan(root,&install,&candidate_manifest,&BTreeMap::new(),None)?.into_iter().filter(|p|p.action==update::Action::Conflict).map(|p|(p.path,update::Resolution::Replace)).collect();
|
||||||
|
let env=Offline(RepackEnv{root,meta_dir:meta});
|
||||||
|
let outcome=update::apply(&update::Params {root,meta_dir:meta,source,trusted_key:coa_core::signing::EMBEDDED_PUBLIC_KEY,cancel:Default::default(),resolutions:choices,env:&env,fail_after_ops:None},&|stage,pct|println!("{pct}% {stage}"))?;
|
||||||
|
assert_eq!(outcome.txn.state,update::State::Committed,"{:?}",outcome.txn.message);
|
||||||
|
backup::with_database(root,|db| {
|
||||||
|
for (index,mode) in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().enumerate() {
|
||||||
|
let db=db.clone().for_realm(mode);
|
||||||
|
assert_eq!(players(&db)?,expected[index],"Player data changed in {}",mode.name());
|
||||||
|
assert!(coa_core::schema_check::check(&db,root)?.is_empty());
|
||||||
|
assert_eq!(migrations::pending_count(&db,&candidate_manifest.migrations)?,0);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
println!("PASS: signed historical package to candidate; missing Wildcard table repaired in both realms; player fields and inventory preserved; schema and startup verified. Launcher isolation was test instrumentation, not packaged UI acceptance.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ fn main() {
|
|||||||
let dst = staging.join(db).join(format!("{id}.sql"));
|
let dst = staging.join(db).join(format!("{id}.sql"));
|
||||||
std::fs::create_dir_all(dst.parent().unwrap()).unwrap();
|
std::fs::create_dir_all(dst.parent().unwrap()).unwrap();
|
||||||
std::fs::write(&dst, &bytes).unwrap();
|
std::fs::write(&dst, &bytes).unwrap();
|
||||||
list.push(Migration { id, db: db.into(), sha256: fsx::sha256_bytes(&bytes), destructive: false });
|
list.push(Migration { compatible_sha256: vec![], id, db: db.into(), sha256: fsx::sha256_bytes(&bytes), destructive: false });
|
||||||
};
|
};
|
||||||
for (kind, dirs) in [("auth", ["db_auth", "pending_db_auth"]), ("characters", ["db_characters", "pending_db_characters"]), ("world", ["db_world", "pending_db_world"])] {
|
for (kind, dirs) in [("auth", ["db_auth", "pending_db_auth"]), ("characters", ["db_characters", "pending_db_characters"]), ("world", ["db_world", "pending_db_world"])] {
|
||||||
for d in dirs {
|
for d in dirs {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ fn main() -> Result<()> {
|
|||||||
let path = staged.join(format!("{kind}/manager_realm_fixture.sql"));
|
let path = staged.join(format!("{kind}/manager_realm_fixture.sql"));
|
||||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
std::fs::write(&path, sql)?;
|
std::fs::write(&path, sql)?;
|
||||||
manifest.migrations.push(Migration { id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false });
|
manifest.migrations.push(Migration { compatible_sha256: vec![], id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false });
|
||||||
}
|
}
|
||||||
let result = env.migrate(&manifest, &staged)?;
|
let result = env.migrate(&manifest, &staged)?;
|
||||||
assert!(result.failed.is_none());
|
assert!(result.failed.is_none());
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fn main() -> Result<()> {
|
|||||||
fsx::atomic_write(&update_tree.join(format!("_migrations/world/{id}.sql")), sql.as_bytes())?;
|
fsx::atomic_write(&update_tree.join(format!("_migrations/world/{id}.sql")), sql.as_bytes())?;
|
||||||
let options = |kind, version: &str, migrations| BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1 << 20, bots_commit: None, migrations };
|
let options = |kind, version: &str, migrations| BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1 << 20, bots_commit: None, migrations };
|
||||||
let base = package::build(&base_tree, &base_pkg, &options(Kind::Base, "1.0.0", vec![]), &|_| {})?;
|
let base = package::build(&base_tree, &base_pkg, &options(Kind::Base, "1.0.0", vec![]), &|_| {})?;
|
||||||
let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?;
|
let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { compatible_sha256: vec![], id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?;
|
||||||
// A test-only trust key, unrelated to production signing material.
|
// A test-only trust key, unrelated to production signing material.
|
||||||
let key = SigningKey::from_bytes(&[23; 32]);
|
let key = SigningKey::from_bytes(&[23; 32]);
|
||||||
let public = STANDARD.encode(key.verifying_key().as_bytes());
|
let public = STANDARD.encode(key.verifying_key().as_bytes());
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ fn main() {
|
|||||||
let sql = "CREATE TABLE IF NOT EXISTS coa_manager_update_test (id INT PRIMARY KEY); INSERT IGNORE INTO coa_manager_update_test VALUES (1);";
|
let sql = "CREATE TABLE IF NOT EXISTS coa_manager_update_test (id INT PRIMARY KEY); INSERT IGNORE INTO coa_manager_update_test VALUES (1);";
|
||||||
put(&src, "_migrations/world/2026_09_30_00_update_e2e.sql", sql);
|
put(&src, "_migrations/world/2026_09_30_00_update_e2e.sql", sql);
|
||||||
let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "0.2.0".into(), core_commit: None, built_at: chrono::Utc::now().to_rfc3339(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap();
|
let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "0.2.0".into(), core_commit: None, built_at: chrono::Utc::now().to_rfc3339(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap();
|
||||||
m.migrations.push(Migration { id: "2026_09_30_00_update_e2e".into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
|
m.migrations.push(Migration { compatible_sha256: vec![], id: "2026_09_30_00_update_e2e".into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
|
||||||
std::fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap();
|
std::fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap();
|
||||||
let key = format!("{}/.coa-manager/signing/manifest-signing.key", std::env::var("USERPROFILE").unwrap());
|
let key = format!("{}/.coa-manager/signing/manifest-signing.key", std::env::var("USERPROFILE").unwrap());
|
||||||
let seed: [u8; 32] = STANDARD.decode(std::fs::read_to_string(key).unwrap().trim()).unwrap().try_into().unwrap();
|
let seed: [u8; 32] = STANDARD.decode(std::fs::read_to_string(key).unwrap().trim()).unwrap().try_into().unwrap();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ fn exercise(root: &Path) -> Result<()> {
|
|||||||
("world", "probe_world", "DROP TABLE manager_recovery_probe; THIS IS INTENTIONALLY INVALID SQL;"),
|
("world", "probe_world", "DROP TABLE manager_recovery_probe; THIS IS INTENTIONALLY INVALID SQL;"),
|
||||||
] {
|
] {
|
||||||
fsx::atomic_write(&source.join(format!("_migrations/{db}/{id}.sql")), sql.as_bytes())?;
|
fsx::atomic_write(&source.join(format!("_migrations/{db}/{id}.sql")), sql.as_bytes())?;
|
||||||
migrations.push(Migration { db: db.into(), id: id.into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
|
migrations.push(Migration { compatible_sha256: vec![], db: db.into(), id: id.into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
|
||||||
}
|
}
|
||||||
let pkg = root.join("fixture-package");
|
let pkg = root.join("fixture-package");
|
||||||
let manifest = package::build(&source, &pkg, &BuildOptions {
|
let manifest = package::build(&source, &pkg, &BuildOptions {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ fn main() -> Result<()> {
|
|||||||
let path = old_sql.join(format!("{name}.sql"));
|
let path = old_sql.join(format!("{name}.sql"));
|
||||||
let hash = fsx::sha256_file(&path)?;
|
let hash = fsx::sha256_file(&path)?;
|
||||||
db.put(&LedgerRow { db: "characters".into(), id: name.into(), sha256: hash.clone(), status: Status::Applied, error: None, baseline: false })?;
|
db.put(&LedgerRow { db: "characters".into(), id: name.into(), sha256: hash.clone(), status: Status::Applied, error: None, baseline: false })?;
|
||||||
migrations.push(Migration { db: "characters".into(), id: name.into(), sha256: hash, destructive: false });
|
migrations.push(Migration { compatible_sha256: vec![], db: "characters".into(), id: name.into(), sha256: hash, destructive: false });
|
||||||
}
|
}
|
||||||
for (_, tables) in schema_check::WILDCARD_TABLES.iter().filter(|(kind,_)| *kind == "characters") {
|
for (_, tables) in schema_check::WILDCARD_TABLES.iter().filter(|(kind,_)| *kind == "characters") {
|
||||||
for table in *tables { db.query(&format!("DROP TABLE IF EXISTS acore_characters.`{table}`;"))?; }
|
for table in *tables { db.query(&format!("DROP TABLE IF EXISTS acore_characters.`{table}`;"))?; }
|
||||||
@@ -33,7 +33,7 @@ fn main() -> Result<()> {
|
|||||||
let files = release_schema::collect(empty_core.path(), None, Some(&repairs))?;
|
let files = release_schema::collect(empty_core.path(), None, Some(&repairs))?;
|
||||||
assert_eq!(files.len(), 1);
|
assert_eq!(files.len(), 1);
|
||||||
let repair = &files[0];
|
let repair = &files[0];
|
||||||
migrations.push(Migration { db: repair.db.clone(), id: repair.id.clone(), sha256: repair.sha256.clone(), destructive: false });
|
migrations.push(Migration { compatible_sha256: vec![], db: repair.db.clone(), id: repair.id.clone(), sha256: repair.sha256.clone(), destructive: false });
|
||||||
let staged = tree.path().join("sql/characters");
|
let staged = tree.path().join("sql/characters");
|
||||||
fsx::atomic_write(&staged.join(format!("{}.sql", repair.id)), &std::fs::read(&repair.abs)?)?;
|
fsx::atomic_write(&staged.join(format!("{}.sql", repair.id)), &std::fs::read(&repair.abs)?)?;
|
||||||
let report = migrations::apply_pending(&db, &migrations, &tree.path().join("sql"), &|| Ok("disposable fixture".into()))?;
|
let report = migrations::apply_pending(&db, &migrations, &tree.path().join("sql"), &|| Ok("disposable fixture".into()))?;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub struct Component {
|
|||||||
pub sha256: Option<String>,
|
pub sha256: Option<String>,
|
||||||
pub tables: Option<usize>,
|
pub tables: Option<usize>,
|
||||||
pub files: Option<Vec<String>>,
|
pub files: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_sha256: std::collections::BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -91,7 +93,9 @@ pub fn list(meta: &Path) -> Vec<RecoveryPoint> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(meta: &Path, id: &str) -> Result<RecoveryPoint> {
|
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")))
|
let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(meta, &point_dir(meta, id)?.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?;
|
||||||
|
if point.id != id || point.schema != 1 { return Err(Error::Invalid(format!("Backup {id} has inconsistent identity or an unsupported schema."))); }
|
||||||
|
Ok(point)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets.
|
/// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets.
|
||||||
@@ -139,13 +143,16 @@ fn copy_configs(root: &Path, dir: &Path) -> Result<Component> {
|
|||||||
let files = config_files(root);
|
let files = config_files(root);
|
||||||
let target = dir.join("files");
|
let target = dir.join("files");
|
||||||
let mut bytes = 0;
|
let mut bytes = 0;
|
||||||
|
let mut file_sha256 = std::collections::BTreeMap::new();
|
||||||
for rel in &files {
|
for rel in &files {
|
||||||
let src = fsx::safe_join(root, rel)?;
|
let src = fsx::safe_join(root, rel)?;
|
||||||
let dst = fsx::safe_join(&target, rel)?;
|
let dst = fsx::safe_join(&target, rel)?;
|
||||||
fs::create_dir_all(dst.parent().unwrap())?;
|
let content = fs::read(&src)?;
|
||||||
bytes += fs::copy(&src, &dst)?;
|
fsx::atomic_write(&dst, &content)?;
|
||||||
|
bytes += content.len() as u64;
|
||||||
|
file_sha256.insert(rel.clone(), fsx::sha256_bytes(&content));
|
||||||
}
|
}
|
||||||
Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files) })
|
Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files), file_sha256 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again.
|
/// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again.
|
||||||
@@ -215,7 +222,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
|
|||||||
let tables = db.tables(schema)?.len();
|
let tables = db.tables(schema)?.len();
|
||||||
let file = format!("{name}.sql.zst");
|
let file = format!("{name}.sql.zst");
|
||||||
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
|
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
|
||||||
Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
|
Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None, file_sha256: Default::default() })
|
||||||
})?;
|
})?;
|
||||||
components.push(component);
|
components.push(component);
|
||||||
}
|
}
|
||||||
@@ -255,6 +262,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
|
|||||||
|
|
||||||
/// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned.
|
/// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned.
|
||||||
pub fn prune_automatic(meta: &Path, keep: usize) {
|
pub fn prune_automatic(meta: &Path, keep: usize) {
|
||||||
|
if crate::update::ensure_recovered(meta).is_err() { return; }
|
||||||
let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect();
|
let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect();
|
||||||
for p in autos.into_iter().skip(keep) {
|
for p in autos.into_iter().skip(keep) {
|
||||||
if let Ok(dir) = point_dir(meta, &p.id) {
|
if let Ok(dir) = point_dir(meta, &p.id) {
|
||||||
@@ -273,8 +281,21 @@ pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
|
|||||||
let point = get(meta, id)?;
|
let point = get(meta, id)?;
|
||||||
let dir = point_dir(meta, id)?;
|
let dir = point_dir(meta, id)?;
|
||||||
let mut problems = Vec::new();
|
let mut problems = Vec::new();
|
||||||
|
let mut names = std::collections::BTreeSet::new();
|
||||||
|
let mut schemas = std::collections::BTreeSet::new();
|
||||||
for c in &point.components {
|
for c in &point.components {
|
||||||
let path = dir.join(&c.path);
|
if !names.insert(&c.name) { problems.push(format!("{} appears more than once", c.name)); }
|
||||||
|
let path = fsx::ensure_within(&dir, &fsx::safe_join(&dir, &c.path)?)?;
|
||||||
|
if c.name != "configs" && (c.sha256.is_none() || c.tables.is_none()) { problems.push(format!("{} lacks database integrity metadata", c.name)); }
|
||||||
|
if c.name == "configs" {
|
||||||
|
if c.sha256.is_some() || c.files.is_none() { problems.push("configuration integrity metadata is inconsistent".into()); }
|
||||||
|
} else {
|
||||||
|
let schema = if c.name.contains('-') || c.name == "playerbots" { db::schema_of(&c.name) } else { point.realm.schema(&c.name) };
|
||||||
|
match schema {
|
||||||
|
Ok(schema) if schemas.insert(schema) => {}
|
||||||
|
_ => problems.push(format!("{} has an unknown or duplicate database target", c.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
match (&c.sha256, &c.files) {
|
match (&c.sha256, &c.files) {
|
||||||
(Some(sha), _) => match fsx::sha256_file(&path) {
|
(Some(sha), _) => match fsx::sha256_file(&path) {
|
||||||
Ok(actual) if actual.eq_ignore_ascii_case(sha) => {}
|
Ok(actual) if actual.eq_ignore_ascii_case(sha) => {}
|
||||||
@@ -283,12 +304,16 @@ pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
|
|||||||
},
|
},
|
||||||
(None, Some(files)) => {
|
(None, Some(files)) => {
|
||||||
for f in files {
|
for f in files {
|
||||||
|
if !c.file_sha256.is_empty() && !c.file_sha256.contains_key(f) { problems.push(format!("configuration file {f} lacks its checksum")); }
|
||||||
if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) {
|
if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) {
|
||||||
problems.push(format!("configuration file {f} is missing"));
|
problems.push(format!("configuration file {f} is missing"));
|
||||||
|
} else if let Some(expected) = c.file_sha256.get(f) {
|
||||||
|
let file = fsx::ensure_within(&dir, &fsx::safe_join(&path, f)?)?;
|
||||||
|
if fsx::sha256_file(&file)?.as_str() != expected { problems.push(format!("configuration file {f} is damaged")); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => problems.push(format!("{} lacks an integrity inventory", c.name)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(VerifyReport { ok: problems.is_empty(), problems })
|
Ok(VerifyReport { ok: problems.is_empty(), problems })
|
||||||
@@ -296,6 +321,8 @@ pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
|
|||||||
|
|
||||||
/// Delete one recovery point (its own folder only).
|
/// Delete one recovery point (its own folder only).
|
||||||
pub fn delete(meta: &Path, id: &str) -> Result<()> {
|
pub fn delete(meta: &Path, id: &str) -> Result<()> {
|
||||||
|
let _lock = crate::update::operation_lock(meta)?;
|
||||||
|
crate::update::ensure_recovered(meta)?;
|
||||||
if crate::update::unfinished(meta).is_some_and(|t| t.recovery_point.as_deref() == Some(id)) {
|
if crate::update::unfinished(meta).is_some_and(|t| t.recovery_point.as_deref() == Some(id)) {
|
||||||
return Err(Error::Invalid("This recovery point is required by an unfinished update and cannot be deleted.".into()));
|
return Err(Error::Invalid("This recovery point is required by an unfinished update and cannot be deleted.".into()));
|
||||||
}
|
}
|
||||||
@@ -405,6 +432,36 @@ mod tests {
|
|||||||
(dir, root, meta)
|
(dir, root, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_deletion_obeys_the_installation_lock() {
|
||||||
|
let (_d, root, meta) = setup();
|
||||||
|
let point = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap();
|
||||||
|
let lock = crate::update::operation_lock(&meta).unwrap();
|
||||||
|
assert!(delete(&meta, &point.id).is_err());
|
||||||
|
assert!(get(&meta, &point.id).is_ok());
|
||||||
|
drop(lock);
|
||||||
|
delete(&meta, &point.id).unwrap();
|
||||||
|
assert!(get(&meta, &point.id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damaged_configuration_is_detected_before_restore_and_copies_are_retained() {
|
||||||
|
let (_d, root, meta) = setup();
|
||||||
|
let point = create(&root, &meta, Kind::Config, Trigger::Automatic, None, &|_| {}).unwrap();
|
||||||
|
let file = point_dir(&meta, &point.id).unwrap().join("files/Core/configs/worldserver.conf");
|
||||||
|
fs::write(&file, b"corrupted").unwrap();
|
||||||
|
assert!(!verify(&meta, &point.id).unwrap().ok);
|
||||||
|
let original = fs::read(root.join("Core/configs/worldserver.conf")).unwrap();
|
||||||
|
assert!(restore_configs(&root, &meta, &point.id).is_err());
|
||||||
|
assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), original);
|
||||||
|
assert_eq!(list(&meta).len(), 1);
|
||||||
|
fs::create_dir_all(meta.join("updates/broken")).unwrap();
|
||||||
|
fs::write(meta.join("updates/broken/txn.json"), b"{damaged").unwrap();
|
||||||
|
assert!(delete(&meta, &point.id).is_err());
|
||||||
|
prune_automatic(&meta, 0);
|
||||||
|
assert!(file.exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() {
|
fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() {
|
||||||
let (_d, root, meta) = setup();
|
let (_d, root, meta) = setup();
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> {
|
|||||||
if let Some(b) = p.bots {
|
if let Some(b) = p.bots {
|
||||||
sql.extend(collect_bots_sql(b)?);
|
sql.extend(collect_bots_sql(b)?);
|
||||||
}
|
}
|
||||||
let migrations: Vec<_> = sql.iter().map(|f| crate::manifest::Migration { id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
|
let migrations: Vec<_> = sql.iter().map(|f| crate::manifest::Migration { compatible_sha256: vec![], id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
|
||||||
let staging = std::env::temp_dir().join("coa-cleanbase-sql");
|
let staging = std::env::temp_dir().join("coa-cleanbase-sql");
|
||||||
let _ = fs::remove_dir_all(&staging);
|
let _ = fs::remove_dir_all(&staging);
|
||||||
for f in &sql {
|
for f in &sql {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
|||||||
let tmp = parent.join(format!(
|
let tmp = parent.join(format!(
|
||||||
".{}.{}.tmp",
|
".{}.{}.tmp",
|
||||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||||
std::process::id()
|
uuid::Uuid::new_v4().simple()
|
||||||
));
|
));
|
||||||
let write = || -> std::io::Result<()> {
|
let write = || -> std::io::Result<()> {
|
||||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||||
@@ -135,13 +135,41 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
|||||||
let _ = fs::remove_file(&tmp);
|
let _ = fs::remove_file(&tmp);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
if let Err(e) = fs::rename(&tmp, path) {
|
if let Err(e) = durable_replace(&tmp, path) {
|
||||||
let _ = fs::remove_file(&tmp);
|
let _ = fs::remove_file(&tmp);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The temporary file must already be flushed, on the same volume as the destination.
|
||||||
|
pub(crate) fn durable_replace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH};
|
||||||
|
fn wide(path: &Path) -> std::io::Result<Vec<u16>> {
|
||||||
|
let absolute = std::path::absolute(path)?;
|
||||||
|
let text = absolute.as_os_str().to_string_lossy().replace('/', "\\");
|
||||||
|
let extended = if text.starts_with("\\\\?\\") { text }
|
||||||
|
else if let Some(unc) = text.strip_prefix("\\\\") { format!("\\\\?\\UNC\\{unc}") }
|
||||||
|
else { format!("\\\\?\\{text}") };
|
||||||
|
Ok(std::ffi::OsStr::new(&extended).encode_wide().chain(Some(0)).collect())
|
||||||
|
}
|
||||||
|
let src = wide(from)?;
|
||||||
|
let dst = wide(to)?;
|
||||||
|
if unsafe { MoveFileExW(src.as_ptr(), dst.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) } == 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
fs::rename(from, to)?;
|
||||||
|
File::open(to.parent().unwrap())?.sync_all()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
|
pub fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||||
let mut bytes = serde_json::to_vec_pretty(value)?;
|
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||||
bytes.push(b'\n');
|
bytes.push(b'\n');
|
||||||
@@ -199,6 +227,16 @@ pub fn require_space(path: &Path, needed: u64) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_replacement_handles_long_transaction_backup_paths() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let parent = dir.path().join("a".repeat(90)).join("b".repeat(90));
|
||||||
|
let path = parent.join("configuration-with-a-long-name.template");
|
||||||
|
atomic_write(&path, b"old").unwrap();
|
||||||
|
atomic_write(&path, b"new").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"new");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn safe_join_accepts_normal_and_rejects_traversal() {
|
fn safe_join_accepts_normal_and_rejects_traversal() {
|
||||||
let root = Path::new("C:/srv");
|
let root = Path::new("C:/srv");
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub struct FileEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Migration {
|
pub struct Migration {
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub compatible_sha256: Vec<String>,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub db: String,
|
pub db: String,
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
@@ -134,6 +136,11 @@ impl Manifest {
|
|||||||
if parse_version(&self.min_manager_version).is_none() {
|
if parse_version(&self.min_manager_version).is_none() {
|
||||||
return bad(format!("bad minManagerVersion {:?}", self.min_manager_version));
|
return bad(format!("bad minManagerVersion {:?}", self.min_manager_version));
|
||||||
}
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
for (label, rev) in [("core", Some(&self.core)), ("bots", self.bots.as_ref())] {
|
for (label, rev) in [("core", Some(&self.core)), ("bots", self.bots.as_ref())] {
|
||||||
if let Some(Revision { commit: Some(c) }) = rev {
|
if let Some(Revision { commit: Some(c) }) = rev {
|
||||||
if !is_commit(c) {
|
if !is_commit(c) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub struct LedgerRow {
|
|||||||
/// Storage for the ledger and the ability to run a SQL file; implemented by the real database and by tests.
|
/// Storage for the ledger and the ability to run a SQL file; implemented by the real database and by tests.
|
||||||
pub trait Store {
|
pub trait Store {
|
||||||
fn load(&self) -> Result<Vec<LedgerRow>>;
|
fn load(&self) -> Result<Vec<LedgerRow>>;
|
||||||
|
fn load_readonly(&self) -> Result<Vec<LedgerRow>> { self.load() }
|
||||||
fn put(&self, row: &LedgerRow) -> Result<()>;
|
fn put(&self, row: &LedgerRow) -> Result<()>;
|
||||||
fn run_file(&self, schema: &str, file: &Path) -> Result<()>;
|
fn run_file(&self, schema: &str, file: &Path) -> Result<()>;
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,13 @@ impl Store for Db {
|
|||||||
self.query(&format!(
|
self.query(&format!(
|
||||||
"CREATE TABLE IF NOT EXISTS `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
|
"CREATE TABLE IF NOT EXISTS `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
|
||||||
))?;
|
))?;
|
||||||
let out = self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))?;
|
self.load_readonly()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_readonly(&self) -> Result<Vec<LedgerRow>> {
|
||||||
|
let out = if self.tables(LEDGER_SCHEMA)?.iter().any(|name| name == LEDGER_TABLE) {
|
||||||
|
self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))?
|
||||||
|
} else { String::new() };
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
// Files the core's own updater (or whoever prepared this database) already recorded count as applied.
|
// Files the core's own updater (or whoever prepared this database) already recorded count as applied.
|
||||||
for (kind, schema) in db::SCHEMAS {
|
for (kind, schema) in db::SCHEMAS {
|
||||||
@@ -149,6 +156,50 @@ fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> {
|
|||||||
|
|
||||||
fn hash_changed(row: &LedgerRow, m: &Migration) -> bool {
|
fn hash_changed(row: &LedgerRow, m: &Migration) -> bool {
|
||||||
row.status == Status::Applied && row.sha256 != "0".repeat(64) && !row.sha256.eq_ignore_ascii_case(&m.sha256)
|
row.status == Status::Applied && row.sha256 != "0".repeat(64) && !row.sha256.eq_ignore_ascii_case(&m.sha256)
|
||||||
|
&& !m.compatible_sha256.iter().any(|hash| hash.eq_ignore_ascii_case(&row.sha256))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect recorded history without creating a ledger or executing migrations.
|
||||||
|
pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> {
|
||||||
|
check_history(&store.load_readonly()?, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result<usize> {
|
||||||
|
let rows = store.load_readonly()?;
|
||||||
|
check_history(&rows, list)?;
|
||||||
|
Ok(list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).count())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> {
|
||||||
|
if !ident_ok(&m.id) { return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id))); }
|
||||||
|
let schema = db::schema_of(&m.db)?;
|
||||||
|
let path = fsx::safe_join(dir, &format!("{}/{}.sql", m.db, m.id))?;
|
||||||
|
let bytes = std::fs::read(&path).map_err(|_| Error::Invalid(format!("migration file {} is missing", m.id)))?;
|
||||||
|
let actual = fsx::sha256_bytes(&bytes);
|
||||||
|
if !actual.eq_ignore_ascii_case(&m.sha256) { return Err(Error::HashMismatch { path: path.display().to_string(), expected: m.sha256.clone(), actual }); }
|
||||||
|
Ok((schema, path, m.destructive || looks_destructive(&String::from_utf8_lossy(&bytes))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check every migration artifact, including applied history, before replacing server files.
|
||||||
|
pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for m in list {
|
||||||
|
if !seen.insert((&m.db, &m.id)) { return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); }
|
||||||
|
verified_file(m, dir)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> {
|
||||||
|
for m in list {
|
||||||
|
if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) {
|
||||||
|
return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db)));
|
||||||
|
}
|
||||||
|
if recorded(rows, m).is_some_and(|r| hash_changed(r, m)) {
|
||||||
|
return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record migrations as already present without running them (adopting an existing server whose database
|
/// Record migrations as already present without running them (adopting an existing server whose database
|
||||||
@@ -178,14 +229,7 @@ pub struct ApplyReport {
|
|||||||
pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result<String>) -> Result<ApplyReport> {
|
pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result<String>) -> Result<ApplyReport> {
|
||||||
let rows = store.load()?;
|
let rows = store.load()?;
|
||||||
// The list order is the release's order of application (released updates, then pending, then modules).
|
// The list order is the release's order of application (released updates, then pending, then modules).
|
||||||
for m in list {
|
check_history(&rows, list)?;
|
||||||
if recorded(&rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) {
|
|
||||||
return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db)));
|
|
||||||
}
|
|
||||||
if recorded(&rows, m).is_some_and(|r| hash_changed(r, m)) {
|
|
||||||
return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect();
|
let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect();
|
||||||
tracing::info!(total = list.len(), pending = todo.len(), skipped = list.len() - todo.len(), "database migration plan");
|
tracing::info!(total = list.len(), pending = todo.len(), skipped = list.len() - todo.len(), "database migration plan");
|
||||||
|
|
||||||
@@ -193,17 +237,8 @@ pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot
|
|||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut any_destructive = false;
|
let mut any_destructive = false;
|
||||||
for m in &todo {
|
for m in &todo {
|
||||||
if !ident_ok(&m.id) {
|
let (schema, path, destructive) = verified_file(m, dir)?;
|
||||||
return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id)));
|
any_destructive |= destructive;
|
||||||
}
|
|
||||||
let schema = db::schema_of(&m.db)?;
|
|
||||||
let path = fsx::safe_join(dir, &format!("{}/{}.sql", m.db, m.id))?;
|
|
||||||
let bytes = std::fs::read(&path).map_err(|_| Error::Invalid(format!("migration file {} is missing", m.id)))?;
|
|
||||||
let actual = fsx::sha256_bytes(&bytes);
|
|
||||||
if !actual.eq_ignore_ascii_case(&m.sha256) {
|
|
||||||
return Err(Error::HashMismatch { path: path.display().to_string(), expected: m.sha256.clone(), actual });
|
|
||||||
}
|
|
||||||
any_destructive |= m.destructive || looks_destructive(&String::from_utf8_lossy(&bytes));
|
|
||||||
files.push((*m, schema, path));
|
files.push((*m, schema, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +308,7 @@ mod tests {
|
|||||||
for (db, id, sql) in files {
|
for (db, id, sql) in files {
|
||||||
std::fs::create_dir_all(d.path().join(db)).unwrap();
|
std::fs::create_dir_all(d.path().join(db)).unwrap();
|
||||||
std::fs::write(d.path().join(db).join(format!("{id}.sql")), sql).unwrap();
|
std::fs::write(d.path().join(db).join(format!("{id}.sql")), sql).unwrap();
|
||||||
list.push(Migration { id: id.to_string(), db: db.to_string(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
|
list.push(Migration { compatible_sha256: vec![], id: id.to_string(), db: db.to_string(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
|
||||||
}
|
}
|
||||||
(d, list)
|
(d, list)
|
||||||
}
|
}
|
||||||
@@ -304,6 +339,60 @@ mod tests {
|
|||||||
assert!(store.ran.borrow().is_empty());
|
assert!(store.ran.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() {
|
||||||
|
let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
|
||||||
|
verify_files(&list, dir.path()).unwrap();
|
||||||
|
let mut bad = list.clone();
|
||||||
|
bad[0].id = "../outside".into();
|
||||||
|
assert!(verify_files(&bad, dir.path()).is_err());
|
||||||
|
bad = list.clone();
|
||||||
|
bad.push(list[0].clone());
|
||||||
|
assert!(verify_files(&bad, dir.path()).is_err());
|
||||||
|
std::fs::write(dir.path().join("characters/repair.sql"), b"SELECT 2;\n").unwrap();
|
||||||
|
assert!(verify_files(&list, dir.path()).is_err());
|
||||||
|
std::fs::remove_file(dir.path().join("characters/repair.sql")).unwrap();
|
||||||
|
assert!(verify_files(&list, dir.path()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_uses_readonly_history_and_rejects_conflicts_without_writes() {
|
||||||
|
struct ReadOnly<'a>(&'a Mem);
|
||||||
|
impl Store for ReadOnly<'_> {
|
||||||
|
fn load(&self) -> Result<Vec<LedgerRow>> { panic!("preflight must use readonly access") }
|
||||||
|
fn load_readonly(&self) -> Result<Vec<LedgerRow>> { self.0.load() }
|
||||||
|
fn put(&self, _: &LedgerRow) -> Result<()> { panic!("preflight must not rewrite history") }
|
||||||
|
fn run_file(&self, _: &str, _: &Path) -> Result<()> { panic!("preflight must not run SQL") }
|
||||||
|
}
|
||||||
|
let (_d, list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
|
||||||
|
for state in [Status::Applied, Status::Running, Status::Failed] {
|
||||||
|
let store = Mem::default();
|
||||||
|
store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "repair".into(), sha256: "a".repeat(64), status: state, error: None, baseline: false });
|
||||||
|
assert!(preflight(&ReadOnly(&store), &list).is_err());
|
||||||
|
assert_eq!(store.rows.borrow()[0].sha256, "a".repeat(64));
|
||||||
|
assert_eq!(store.rows.borrow()[0].status, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn published_compatibility_preserves_history_and_never_replays_sql() {
|
||||||
|
let (d, mut list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
|
||||||
|
let store = Mem::default();
|
||||||
|
let old = fsx::sha256_bytes(b"SELECT 1;\r\n");
|
||||||
|
store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "repair".into(), sha256: old.clone(), status: Status::Applied, error: None, baseline: false });
|
||||||
|
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
|
||||||
|
list[0].compatible_sha256.push(old.clone());
|
||||||
|
assert_eq!(status(&store, &list).unwrap()[0].status, Status::Applied);
|
||||||
|
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).unwrap().applied.is_empty());
|
||||||
|
assert!(store.ran.borrow().is_empty());
|
||||||
|
assert_eq!(store.rows.borrow()[0].sha256, old);
|
||||||
|
store.rows.borrow_mut()[0].status = Status::Running;
|
||||||
|
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
|
||||||
|
store.rows.borrow_mut()[0].status = Status::Failed;
|
||||||
|
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
|
||||||
|
assert!(store.ran.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn applies_in_order_once_and_records_the_ledger() {
|
fn applies_in_order_once_and_records_the_ledger() {
|
||||||
let (d, list) = setup(&[("world", "2026_09_01_00_a", "CREATE TABLE t (id INT);"), ("world", "2026_09_02_00_b", "ALTER TABLE t ADD c INT;")]);
|
let (d, list) = setup(&[("world", "2026_09_01_00_a", "CREATE TABLE t (id INT);"), ("world", "2026_09_02_00_b", "ALTER TABLE t ADD c INT;")]);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ pub fn collect_bots_sql(bots: &Path) -> Result<Vec<SqlFile>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn to_migrations(files: &[SqlFile]) -> Vec<Migration> {
|
fn to_migrations(files: &[SqlFile]) -> Vec<Migration> {
|
||||||
files.iter().map(|f| Migration { id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect()
|
files.iter().map(|f| Migration { compatible_sha256: vec![], id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UpdateParams<'a> {
|
pub struct UpdateParams<'a> {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub fn capture_release(fixture: &Path, tree: &Path, sql: &[SqlFile]) -> Result<(
|
|||||||
return Err(Error::Invalid("Refusing to apply release SQL to a non-fixture server.".into()));
|
return Err(Error::Invalid("Refusing to apply release SQL to a non-fixture server.".into()));
|
||||||
}
|
}
|
||||||
let staging = tempfile::tempdir()?;
|
let staging = tempfile::tempdir()?;
|
||||||
let migrations: Vec<_> = sql.iter().map(|f| Migration { db: f.db.clone(), id: f.id.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
|
let migrations: Vec<_> = sql.iter().map(|f| Migration { compatible_sha256: vec![], db: f.db.clone(), id: f.id.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
|
||||||
for file in sql {
|
for file in sql {
|
||||||
let path = fsx::safe_join(staging.path(), &format!("{}/{}.sql", file.db, file.id))?;
|
let path = fsx::safe_join(staging.path(), &format!("{}/{}.sql", file.db, file.id))?;
|
||||||
fsx::atomic_write(&path, &std::fs::read(&file.abs)?)?;
|
fsx::atomic_write(&path, &std::fs::read(&file.abs)?)?;
|
||||||
|
|||||||
+298
-27
@@ -77,6 +77,8 @@ pub struct Txn {
|
|||||||
pub databases_started: bool,
|
pub databases_started: bool,
|
||||||
pub ops: Vec<Op>,
|
pub ops: Vec<Op>,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recovery_hashes: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legacy_databases_started() -> bool { true }
|
fn legacy_databases_started() -> bool { true }
|
||||||
@@ -84,6 +86,8 @@ fn legacy_databases_started() -> bool { true }
|
|||||||
/// Everything the transaction needs from the outside world; tests provide a fake.
|
/// Everything the transaction needs from the outside world; tests provide a fake.
|
||||||
pub trait Env {
|
pub trait Env {
|
||||||
fn ensure_stopped(&self) -> Result<()>;
|
fn ensure_stopped(&self) -> Result<()>;
|
||||||
|
fn preflight(&self, _manifest: &Manifest) -> Result<()> { Ok(()) }
|
||||||
|
fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) }
|
||||||
/// Create a recovery point for the databases and configuration; returns its id.
|
/// Create a recovery point for the databases and configuration; returns its id.
|
||||||
fn snapshot(&self) -> Result<String>;
|
fn snapshot(&self) -> Result<String>;
|
||||||
fn restore_snapshot(&self, id: &str) -> Result<()>;
|
fn restore_snapshot(&self, id: &str) -> Result<()>;
|
||||||
@@ -108,32 +112,72 @@ fn save(meta: &Path, t: &Txn) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(meta: &Path, id: &str) -> Result<Txn> {
|
pub fn load(meta: &Path, id: &str) -> Result<Txn> {
|
||||||
fsx::read_json(&txn_dir(meta, id)?.join("txn.json"))
|
read_journal(&txn_dir(meta, id)?.join("txn.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_journal(path: &Path) -> Result<Txn> {
|
||||||
|
let txn: Txn = fsx::read_json(path).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Available recovery copies were preserved.", path.display())))?;
|
||||||
|
if path.parent().and_then(Path::file_name).and_then(|id| id.to_str()) != Some(txn.id.as_str()) { return Err(Error::Invalid("Update journal identity is inconsistent.".into())); }
|
||||||
|
txn_dir(path.parent().unwrap(), &txn.id)?;
|
||||||
|
if let Some(id) = &txn.recovery_point {
|
||||||
|
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || id.contains("..") { return Err(Error::Invalid("Update journal contains an invalid recovery point identity.".into())); }
|
||||||
|
}
|
||||||
|
let valid_hash = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit());
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for op in &txn.ops {
|
||||||
|
fsx::safe_join(path.parent().unwrap(), &op.path)?;
|
||||||
|
if !seen.insert(op.path.replace('\\', "/").to_ascii_lowercase()) || !valid_hash(&op.new_sha256)
|
||||||
|
|| (op.done && !op.started && !matches!(op.action, Action::Skip | Action::Conflict))
|
||||||
|
|| (txn.state == State::Prepared && op.started)
|
||||||
|
|| (txn.state == State::Committed && !op.done) {
|
||||||
|
return Err(Error::Invalid("Update journal contains inconsistent file operations.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if txn.recovery_hashes.values().any(|hash| !valid_hash(hash)) { return Err(Error::Invalid("Update journal contains invalid recovery hashes.".into())); }
|
||||||
|
for key in txn.recovery_hashes.keys().filter(|key| !matches!(key.as_str(), "@install" | "@point")) { fsx::safe_join(path.parent().unwrap(), key)?; }
|
||||||
|
Ok(txn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct CleanupMarker { id: String, state: State }
|
||||||
|
|
||||||
|
fn cleanup_marker(meta: &Path, id: &str) -> Result<PathBuf> {
|
||||||
|
txn_dir(meta, id)?;
|
||||||
|
Ok(meta.join("update-cleanup").join(format!("{id}.json")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn journals(meta: &Path) -> Result<Vec<Txn>> {
|
||||||
|
let entries = match fs::read_dir(updates_dir(meta)) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(e.into()) };
|
||||||
|
let mut all = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_type()?.is_symlink() { return Err(Error::Invalid("Linked update storage cannot be recovered safely.".into())); }
|
||||||
|
if !entry.file_type()?.is_dir() { continue; }
|
||||||
|
let dir = fsx::ensure_within(meta, &entry.path())?;
|
||||||
|
let path = dir.join("txn.json");
|
||||||
|
if !path.is_file() {
|
||||||
|
let id = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let marker = cleanup_marker(meta, &id)?;
|
||||||
|
if fsx::read_json::<CleanupMarker>(&marker).is_ok_and(|m| m.id == id && matches!(m.state, State::Committed | State::RolledBack)) { continue; }
|
||||||
|
return Err(Error::Invalid(format!("Update {id} has no journal. Preserve its recovery copies and inspect it before starting or changing the server.")));
|
||||||
|
}
|
||||||
|
all.push(read_journal(&path)?);
|
||||||
|
}
|
||||||
|
all.sort_by(|a, b| b.id.cmp(&a.id));
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_checked(meta: &Path) -> Result<Option<Txn>> {
|
||||||
|
Ok(journals(meta)?.into_iter().find(|t| !matches!(t.state, State::Committed | State::RolledBack | State::Prepared) && (t.state != State::Failed || t.databases_started || t.ops.iter().any(|o| o.started))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A transaction that started but never reached a final state (crash, power loss). The UI offers rollback.
|
/// A transaction that started but never reached a final state (crash, power loss). The UI offers rollback.
|
||||||
pub fn unfinished(meta: &Path) -> Option<Txn> {
|
pub fn unfinished(meta: &Path) -> Option<Txn> {
|
||||||
let rd = fs::read_dir(updates_dir(meta)).ok()?;
|
pending_checked(meta).ok().flatten()
|
||||||
let mut all: Vec<Txn> = rd.flatten().filter_map(|e| fsx::read_json::<Txn>(&e.path().join("txn.json")).ok()).collect();
|
|
||||||
all.sort_by(|a, b| b.id.cmp(&a.id));
|
|
||||||
all.into_iter().find(|t| !matches!(t.state, State::Committed | State::RolledBack | State::Prepared) && (t.state != State::Failed || t.databases_started || t.ops.iter().any(|o| o.started)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_recovered(meta: &Path) -> Result<()> {
|
pub fn ensure_recovered(meta: &Path) -> Result<()> {
|
||||||
match fs::read_dir(updates_dir(meta)) {
|
if let Some(t) = pending_checked(meta)? {
|
||||||
Ok(entries) => {
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry?;
|
|
||||||
let journal = entry.path().join("txn.json");
|
|
||||||
if journal.exists() {
|
|
||||||
fsx::read_json::<Txn>(&journal).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Recover it before changing or starting the server.", journal.display())))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
if let Some(t) = unfinished(meta) {
|
|
||||||
return Err(Error::Invalid(format!("Resolve unfinished update {} before changing or starting the server.", t.id)));
|
return Err(Error::Invalid(format!("Resolve unfinished update {} before changing or starting the server.", t.id)));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -172,6 +216,12 @@ pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: &
|
|||||||
if would_add { (Action::MergeConfig, Some("new settings are added; your values are kept".into())) } else { (Action::Skip, Some("no new settings".into())) }
|
if would_add { (Action::MergeConfig, Some("new settings are added; your values are kept".into())) } else { (Action::Skip, Some("no new settings".into())) }
|
||||||
}
|
}
|
||||||
(_, Some(c)) if c.eq_ignore_ascii_case(&f.sha256) => (Action::Skip, Some("already up to date".into())),
|
(_, Some(c)) if c.eq_ignore_ascii_case(&f.sha256) => (Action::Skip, Some("already up to date".into())),
|
||||||
|
(_, Some(_)) if f.path == "Scripts/manage.py" && staged.is_some_and(|tree| {
|
||||||
|
match (fs::read_to_string(tree.join(&f.path)), fs::read(&target)) {
|
||||||
|
(Ok(signed), Ok(actual)) => fsx::sha256_bytes(signed.as_bytes()) == f.sha256 && crate::driver::launcher_matches(&signed, &actual),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}) => (Action::Skip, Some("already up to date with Manager integration".into())),
|
||||||
(ReplacePolicy::Replace | ReplacePolicy::ReplaceIfPristine, Some(c)) => {
|
(ReplacePolicy::Replace | ReplacePolicy::ReplaceIfPristine, Some(c)) => {
|
||||||
let manager_launcher = f.path == "Scripts/manage.py" && recorded.is_some_and(|hash| {
|
let manager_launcher = f.path == "Scripts/manage.py" && recorded.is_some_and(|hash| {
|
||||||
fs::read(&target).is_ok_and(|bytes| crate::driver::launcher_matches_recorded(hash, &bytes))
|
fs::read(&target).is_ok_and(|bytes| crate::driver::launcher_matches_recorded(hash, &bytes))
|
||||||
@@ -207,24 +257,72 @@ pub struct Preview {
|
|||||||
pub items: Vec<PlanItem>,
|
pub items: Vec<PlanItem>,
|
||||||
pub conflicts: Vec<String>,
|
pub conflicts: Vec<String>,
|
||||||
pub migrations: usize,
|
pub migrations: usize,
|
||||||
|
pub pending_migrations: usize,
|
||||||
pub download_bytes: u64,
|
pub download_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the signed manifest and describe the update without downloading the payload.
|
/// Describe the update; a managed launcher may need a one-time authenticated payload check.
|
||||||
pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap<String, Resolution>) -> Result<Preview> {
|
pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap<String, Resolution>) -> Result<Preview> {
|
||||||
|
let meta_dir = crate::registry::metadata_dir_for(root)?;
|
||||||
|
let _lock = operation_lock(&meta_dir)?;
|
||||||
|
ensure_recovered(&meta_dir)?;
|
||||||
let (m, _) = fetch_manifest(source, trusted_key)?;
|
let (m, _) = fetch_manifest(source, trusted_key)?;
|
||||||
check_manifest(&m)?;
|
check_manifest(&m)?;
|
||||||
let items = plan(root, meta, &m, resolutions, None)?;
|
reject_downgrade(meta, &m)?;
|
||||||
|
let mut items = plan(root, meta, &m, resolutions, None)?;
|
||||||
|
if items.iter().any(|item| item.path == "Scripts/manage.py" && item.action != Action::Skip)
|
||||||
|
&& launcher_already_integrated(root, &meta_dir, meta, &m, source)? {
|
||||||
|
if let Some(item) = items.iter_mut().find(|item| item.path == "Scripts/manage.py") {
|
||||||
|
item.action = Action::Skip;
|
||||||
|
item.reason = Some("already up to date with Manager integration".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pending_migrations = (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations(&m)?;
|
||||||
Ok(Preview {
|
Ok(Preview {
|
||||||
from_version: meta.core.version.clone(),
|
from_version: meta.core.version.clone(),
|
||||||
to_version: m.version.clone(),
|
to_version: m.version.clone(),
|
||||||
conflicts: items.iter().filter(|i| i.action == Action::Conflict).map(|i| i.path.clone()).collect(),
|
conflicts: items.iter().filter(|i| i.action == Action::Conflict).map(|i| i.path.clone()).collect(),
|
||||||
items,
|
items,
|
||||||
migrations: m.migrations.len(),
|
migrations: m.migrations.len(),
|
||||||
|
pending_migrations,
|
||||||
download_bytes: m.archive.as_ref().map(|a| a.parts.iter().map(|p| p.size).sum()).unwrap_or(0),
|
download_bytes: m.archive.as_ref().map(|a| a.parts.iter().map(|p| p.size).sum()).unwrap_or(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, manifest: &Manifest, source: &Source) -> Result<bool> {
|
||||||
|
let Some(file) = manifest.files.iter().find(|file| file.path == "Scripts/manage.py") else { return Ok(false); };
|
||||||
|
let actual = sha_if_exists(&root.join(&file.path));
|
||||||
|
if actual.as_ref() != meta.original_hashes.get(&file.path) || actual.is_none() { return Ok(false); }
|
||||||
|
let cache = meta_dir.join("manifests").join(format!("launcher-{}.py", file.sha256));
|
||||||
|
let signed = match fs::read(&cache) {
|
||||||
|
Ok(bytes) if fsx::sha256_bytes(&bytes) == file.sha256 => bytes,
|
||||||
|
_ => {
|
||||||
|
let temp = tempfile::tempdir()?;
|
||||||
|
let parts = fetch_parts(source, manifest, &temp.path().join("download"), &Cancel::default(), &|_, _| {})?;
|
||||||
|
package::extract(&parts, manifest, &temp.path().join("tree"), &|_, _| {})?;
|
||||||
|
let bytes = fs::read(temp.path().join("tree").join(&file.path))?;
|
||||||
|
if fsx::sha256_bytes(&bytes) != file.sha256 { return Err(Error::Invalid("The signed launcher failed verification.".into())); }
|
||||||
|
// This derived cache is optional; failure to save it does not change the server.
|
||||||
|
let _ = fsx::atomic_write(&cache, &bytes);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(std::str::from_utf8(&signed).ok().is_some_and(|signed| {
|
||||||
|
fs::read(root.join(&file.path)).ok().is_some_and(|actual| crate::driver::launcher_matches(signed, &actual))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> {
|
||||||
|
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 {
|
||||||
|
return Err(Error::Invalid(format!("Installed server {installed} is newer than package {}. Use its recovery point to undo an update; older packages cannot be applied.", manifest.version)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn check_manifest(m: &Manifest) -> Result<()> {
|
fn check_manifest(m: &Manifest) -> Result<()> {
|
||||||
if m.kind != Kind::Update {
|
if m.kind != Kind::Update {
|
||||||
return Err(Error::Invalid("This is not an update package.".into()));
|
return Err(Error::Invalid("This is not an update package.".into()));
|
||||||
@@ -271,6 +369,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
|
|||||||
let (m, manifest_bytes) = fetch_manifest(&p.source, p.trusted_key)?;
|
let (m, manifest_bytes) = fetch_manifest(&p.source, p.trusted_key)?;
|
||||||
tracing::info!(root = %root.display(), from_version = ?meta.core.version, to_version = %m.version, migrations = m.migrations.len(), "server update selected");
|
tracing::info!(root = %root.display(), from_version = ?meta.core.version, to_version = %m.version, migrations = m.migrations.len(), "server update selected");
|
||||||
check_manifest(&m)?;
|
check_manifest(&m)?;
|
||||||
|
reject_downgrade(&meta, &m)?;
|
||||||
|
p.env.preflight(&m)?;
|
||||||
let archive = m.archive.clone().ok_or_else(|| Error::InvalidManifest("no archive".into()))?;
|
let archive = m.archive.clone().ok_or_else(|| Error::InvalidManifest("no archive".into()))?;
|
||||||
let id = format!("{}-{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), m.version.replace('.', "_"), uuid::Uuid::new_v4().simple());
|
let id = format!("{}-{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), m.version.replace('.', "_"), uuid::Uuid::new_v4().simple());
|
||||||
let tdir = txn_dir(meta_dir, &id)?;
|
let tdir = txn_dir(meta_dir, &id)?;
|
||||||
@@ -282,7 +382,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
|
|||||||
fsx::atomic_write(&tdir.join("manifest.json.sig"), &signature)?;
|
fsx::atomic_write(&tdir.join("manifest.json.sig"), &signature)?;
|
||||||
fsx::atomic_write(&before.join("manager-install.json"), &fs::read(meta_dir.join("install.json"))?)?;
|
fsx::atomic_write(&before.join("manager-install.json"), &fs::read(meta_dir.join("install.json"))?)?;
|
||||||
|
|
||||||
let mut txn = Txn { id: id.clone(), state: State::Prepared, from_version: meta.core.version.clone(), to_version: m.version.clone(), recovery_point: None, databases_started: false, ops: Vec::new(), message: None };
|
let mut txn = Txn { id: id.clone(), state: State::Prepared, from_version: meta.core.version.clone(), to_version: m.version.clone(), recovery_point: None, databases_started: false, ops: Vec::new(), message: None, recovery_hashes: BTreeMap::new() };
|
||||||
|
txn.recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?);
|
||||||
save(meta_dir, &txn)?;
|
save(meta_dir, &txn)?;
|
||||||
|
|
||||||
// Nothing has touched the installation yet; failures up to here just discard the staging area.
|
// Nothing has touched the installation yet; failures up to here just discard the staging area.
|
||||||
@@ -291,6 +392,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
|
|||||||
let parts = fetch_parts(&p.source, &m, &meta_dir.join("staging").join("download"), &p.cancel, &|f, _| step(report, "Downloading the update", 5 + (f * 35.0) as u8))?;
|
let parts = fetch_parts(&p.source, &m, &meta_dir.join("staging").join("download"), &p.cancel, &|f, _| step(report, "Downloading the update", 5 + (f * 35.0) as u8))?;
|
||||||
step(report, "Verifying the update", 42);
|
step(report, "Verifying the update", 42);
|
||||||
package::extract(&parts, &m, &tree, &|_, _| {})?;
|
package::extract(&parts, &m, &tree, &|_, _| {})?;
|
||||||
|
crate::migrations::verify_files(&m.migrations, &tree.join("_migrations"))?;
|
||||||
Ok(tree.clone())
|
Ok(tree.clone())
|
||||||
})();
|
})();
|
||||||
let tree = match staged {
|
let tree = match staged {
|
||||||
@@ -320,6 +422,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
|
|||||||
p.env.ensure_stopped()?;
|
p.env.ensure_stopped()?;
|
||||||
step(report, "Saving a recovery point", 52);
|
step(report, "Saving a recovery point", 52);
|
||||||
txn.recovery_point = Some(p.env.snapshot().map_err(|e| Error::Invalid(format!("The update was not applied because the safety backup failed: {e}")))?);
|
txn.recovery_point = Some(p.env.snapshot().map_err(|e| Error::Invalid(format!("The update was not applied because the safety backup failed: {e}")))?);
|
||||||
|
let point_meta = meta_dir.join("backups").join(txn.recovery_point.as_deref().unwrap()).join("backup.json");
|
||||||
|
if point_meta.is_file() { txn.recovery_hashes.insert("@point".into(), fsx::sha256_file(&point_meta)?); }
|
||||||
|
|
||||||
txn.ops = items
|
txn.ops = items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -458,6 +562,19 @@ fn fail_after_apply(p: &Params, txn: &mut Txn, before: &Path, tree: &Path, why:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, env: &dyn Env) -> Result<()> {
|
fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, env: &dyn Env) -> Result<()> {
|
||||||
|
for (rel, expected) in &txn.recovery_hashes {
|
||||||
|
let saved = match rel.as_str() {
|
||||||
|
"@install" => before.join("manager-install.json"),
|
||||||
|
"@point" => meta.join("backups").join(txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?).join("backup.json"),
|
||||||
|
_ => fsx::safe_join(before, rel)?,
|
||||||
|
};
|
||||||
|
if fsx::sha256_file(&saved)? != *expected { return Err(Error::Invalid(format!("Recovery copy {rel} is damaged; no restoration was started."))); }
|
||||||
|
}
|
||||||
|
if let Some(id) = &txn.recovery_point { env.verify_snapshot(id)?; }
|
||||||
|
for op in txn.ops.iter().filter(|op| op.started && op.had_previous) {
|
||||||
|
if !fsx::safe_join(before, &op.path)?.is_file() { return Err(Error::Invalid(format!("The recovery copy of {} is missing; no restoration was started.", op.path))); }
|
||||||
|
}
|
||||||
|
if !before.join("manager-install.json").is_file() { return Err(Error::Invalid("The saved installation metadata is missing; no restoration was started.".into())); }
|
||||||
env.ensure_stopped()?;
|
env.ensure_stopped()?;
|
||||||
if txn.databases_started {
|
if txn.databases_started {
|
||||||
let id = txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The database recovery point is missing.".into()))?;
|
let id = txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The database recovery point is missing.".into()))?;
|
||||||
@@ -485,6 +602,7 @@ fn apply_ops(root: &Path, tree: &Path, before: &Path, files: &[FileEntry], txn:
|
|||||||
let keep = fsx::safe_join(before, &path)?;
|
let keep = fsx::safe_join(before, &path)?;
|
||||||
fs::create_dir_all(keep.parent().unwrap())?;
|
fs::create_dir_all(keep.parent().unwrap())?;
|
||||||
fsx::atomic_write(&keep, &fs::read(&target)?)?;
|
fsx::atomic_write(&keep, &fs::read(&target)?)?;
|
||||||
|
txn.recovery_hashes.insert(path.clone(), fsx::sha256_file(&keep)?);
|
||||||
}
|
}
|
||||||
// Journal only after the old bytes are safely saved; a crash during backup must not
|
// Journal only after the old bytes are safely saved; a crash during backup must not
|
||||||
// turn an untouched file into a rollback operation with a missing saved copy.
|
// turn an untouched file into a rollback operation with a missing saved copy.
|
||||||
@@ -575,7 +693,10 @@ pub(crate) fn begin_repair(root: &Path, meta_dir: &Path, version: &str, backup:
|
|||||||
if had_previous { fsx::atomic_write(&fsx::safe_join(&before, &file.path)?, &fs::read(&target)?)?; }
|
if had_previous { fsx::atomic_write(&fsx::safe_join(&before, &file.path)?, &fs::read(&target)?)?; }
|
||||||
ops.push(Op { path: file.path.clone(), action: if had_previous { Action::Replace } else { Action::Create }, reason: None, new_sha256: file.sha256.clone(), had_previous, started: true, done: false });
|
ops.push(Op { path: file.path.clone(), action: if had_previous { Action::Replace } else { Action::Create }, reason: None, new_sha256: file.sha256.clone(), had_previous, started: true, done: false });
|
||||||
}
|
}
|
||||||
let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()) };
|
let mut recovery_hashes = BTreeMap::new();
|
||||||
|
recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?);
|
||||||
|
for op in ops.iter().filter(|op| op.had_previous) { recovery_hashes.insert(op.path.clone(), fsx::sha256_file(&fsx::safe_join(&before, &op.path)?)?); }
|
||||||
|
let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()), recovery_hashes };
|
||||||
save(meta_dir, &txn)?;
|
save(meta_dir, &txn)?;
|
||||||
Ok(txn)
|
Ok(txn)
|
||||||
}
|
}
|
||||||
@@ -594,10 +715,15 @@ pub(crate) fn finish_repair(root: &Path, meta_dir: &Path, txn: &mut Txn, success
|
|||||||
|
|
||||||
fn prune(meta_dir: &Path, keep: usize) {
|
fn prune(meta_dir: &Path, keep: usize) {
|
||||||
let Ok(rd) = fs::read_dir(updates_dir(meta_dir)) else { return };
|
let Ok(rd) = fs::read_dir(updates_dir(meta_dir)) else { return };
|
||||||
let mut dirs: Vec<_> = rd.flatten().filter(|e| fsx::read_json::<Txn>(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect();
|
let mut dirs: Vec<_> = rd.flatten().filter(|e| read_journal(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect();
|
||||||
dirs.sort();
|
dirs.sort();
|
||||||
while dirs.len() > keep {
|
while dirs.len() > keep {
|
||||||
let _ = fs::remove_dir_all(dirs.remove(0));
|
let dir = dirs.remove(0);
|
||||||
|
if let Ok(txn) = read_journal(&dir.join("txn.json")) {
|
||||||
|
if let Ok(marker) = cleanup_marker(meta_dir, &txn.id) {
|
||||||
|
if fsx::atomic_write_json(&marker, &CleanupMarker { id: txn.id, state: txn.state }).is_ok() && fs::remove_dir_all(&dir).is_ok() { let _ = fs::remove_file(marker); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +734,20 @@ pub struct RepackEnv<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepackEnv<'_> {
|
impl RepackEnv<'_> {
|
||||||
|
pub fn pending_migrations(&self, manifest: &Manifest) -> Result<usize> {
|
||||||
|
if manifest.migrations.is_empty() { return Ok(0); }
|
||||||
|
crate::backup::with_database(self.root, |db| {
|
||||||
|
let realms = crate::realms::state(self.root)?;
|
||||||
|
let mut modes = vec![realms.active];
|
||||||
|
if realms.wildcard_created { modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); }
|
||||||
|
let mut count = 0;
|
||||||
|
for (index, mode) in modes.into_iter().enumerate() {
|
||||||
|
let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || m.db != "auth").cloned().collect();
|
||||||
|
count += crate::migrations::pending_count(&db.clone().for_realm(mode), &list)?;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
})
|
||||||
|
}
|
||||||
pub(crate) fn verify_recovery_point(&self, point: &crate::backup::RecoveryPoint) -> Result<()> {
|
pub(crate) fn verify_recovery_point(&self, point: &crate::backup::RecoveryPoint) -> Result<()> {
|
||||||
if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) {
|
if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) {
|
||||||
return Err(Error::Invalid("The update recovery point does not contain every required database and configuration.".into()));
|
return Err(Error::Invalid("The update recovery point does not contain every required database and configuration.".into()));
|
||||||
@@ -637,6 +777,18 @@ impl RepackEnv<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Env for RepackEnv<'_> {
|
impl Env for RepackEnv<'_> {
|
||||||
|
fn preflight(&self, manifest: &Manifest) -> Result<()> {
|
||||||
|
self.pending_migrations(manifest).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_snapshot(&self, id: &str) -> Result<()> {
|
||||||
|
let point = crate::backup::get(self.meta_dir, id)?;
|
||||||
|
if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) {
|
||||||
|
return Err(Error::Invalid("The recovery point is incomplete; no restoration was started.".into()));
|
||||||
|
}
|
||||||
|
if !crate::backup::verify(self.meta_dir, id)?.ok { return Err(Error::Invalid("The recovery point is damaged; available copies were preserved.".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn ensure_stopped(&self) -> Result<()> {
|
fn ensure_stopped(&self) -> Result<()> {
|
||||||
use crate::process::{observe, ServiceState};
|
use crate::process::{observe, ServiceState};
|
||||||
let o = observe(self.root, &crate::layout::read_ports(self.root));
|
let o = observe(self.root, &crate::layout::read_ports(self.root));
|
||||||
@@ -774,16 +926,20 @@ mod tests {
|
|||||||
snapshot_ok: Cell<bool>,
|
snapshot_ok: Cell<bool>,
|
||||||
calls: RefCell<Vec<&'static str>>,
|
calls: RefCell<Vec<&'static str>>,
|
||||||
migrate_fail: Cell<bool>,
|
migrate_fail: Cell<bool>,
|
||||||
|
preflight_fail: Cell<bool>,
|
||||||
restore_fail: Cell<bool>,
|
restore_fail: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fake {
|
impl Fake {
|
||||||
fn ok() -> 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), restore_fail: Cell::new(false) }
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Env for Fake {
|
impl Env for Fake {
|
||||||
|
fn preflight(&self, _: &Manifest) -> Result<()> {
|
||||||
|
if self.preflight_fail.get() { Err(Error::Invalid("Conflicting migration history".into())) } else { Ok(()) }
|
||||||
|
}
|
||||||
fn ensure_stopped(&self) -> Result<()> {
|
fn ensure_stopped(&self) -> Result<()> {
|
||||||
self.calls.borrow_mut().push("stop");
|
self.calls.borrow_mut().push("stop");
|
||||||
if self.stopped.get() { Ok(()) } else { Err(Error::Invalid("The server is running.".into())) }
|
if self.stopped.get() { Ok(()) } else { Err(Error::Invalid("The server is running.".into())) }
|
||||||
@@ -854,7 +1010,7 @@ mod tests {
|
|||||||
let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "2.0.0".into(), core_commit: None, built_at: "x".into(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap();
|
let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "2.0.0".into(), core_commit: None, built_at: "x".into(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap();
|
||||||
// Settings/* are merge-config by the packager; make the migration entry visible to the runner
|
// Settings/* are merge-config by the packager; make the migration entry visible to the runner
|
||||||
if migrations {
|
if migrations {
|
||||||
m.migrations.push(crate::manifest::Migration { id: "m1".into(), db: "world".into(), sha256: fsx::sha256_bytes(b"SELECT 1;"), destructive: false });
|
m.migrations.push(crate::manifest::Migration { compatible_sha256: vec![], id: "m1".into(), db: "world".into(), sha256: fsx::sha256_bytes(b"SELECT 1;"), destructive: false });
|
||||||
fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap();
|
fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap();
|
||||||
}
|
}
|
||||||
let sk = SigningKey::generate(&mut rand_core::OsRng);
|
let sk = SigningKey::generate(&mut rand_core::OsRng);
|
||||||
@@ -956,6 +1112,47 @@ mod tests {
|
|||||||
assert!(!w.root.join("Core/worldserver.exe.coa-new").exists());
|
assert!(!w.root.join("Core/worldserver.exe.coa-new").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_integrated_launcher_is_current_but_user_edits_and_new_package_bytes_are_not() {
|
||||||
|
let source = b"import sys\nfrom pathlib import Path\nfrom squid_playerbots import configure\nROOT = Path(__file__).resolve().parents[1]\n";
|
||||||
|
let w = world(&[("Scripts/manage.py", source)], false);
|
||||||
|
run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap();
|
||||||
|
let integrated = crate::driver::patch_launcher_imports(std::str::from_utf8(source).unwrap()).unwrap();
|
||||||
|
write(&w.root, "Scripts/manage.py", integrated.as_bytes());
|
||||||
|
let (_, mut meta) = MetaDir::open(&w.meta).unwrap();
|
||||||
|
meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(integrated.as_bytes()));
|
||||||
|
fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap();
|
||||||
|
let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();
|
||||||
|
assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip);
|
||||||
|
let manifest = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0;
|
||||||
|
let applied_plan = plan(&w.root, &meta, &manifest, &BTreeMap::new(), Some(&w._d.path().join("upd"))).unwrap();
|
||||||
|
assert_eq!(applied_plan.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip);
|
||||||
|
let mut changed = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0;
|
||||||
|
changed.files.iter_mut().find(|file| file.path == "Scripts/manage.py").unwrap().sha256 = fsx::sha256_bytes(b"new launcher");
|
||||||
|
assert!(launcher_already_integrated(&w.root, &w.meta, &meta, &changed, &Source::Dir(w.pkg.clone())).is_err());
|
||||||
|
write(&w.root, "Scripts/manage.py", format!("{integrated}\n# user edit\n").as_bytes());
|
||||||
|
let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();
|
||||||
|
assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Conflict);
|
||||||
|
meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_file(&w.root.join("Scripts/manage.py")).unwrap());
|
||||||
|
let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();
|
||||||
|
assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Replace,
|
||||||
|
"Recorded user content must not be mistaken for a recognized Manager integration");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn older_packages_are_rejected_before_preview_or_application() {
|
||||||
|
let w = world(&[], false);
|
||||||
|
let (_, mut installed) = MetaDir::open(&w.meta).unwrap();
|
||||||
|
installed.core.version = Some("3.0.0".into());
|
||||||
|
fsx::atomic_write_json(&w.meta.join("install.json"), &installed).unwrap();
|
||||||
|
let env = Fake::ok();
|
||||||
|
assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("newer than package"));
|
||||||
|
assert!(preview(&w.root, &installed, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap_err().to_string().contains("newer than package"));
|
||||||
|
assert!(env.calls.borrow().is_empty());
|
||||||
|
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
|
||||||
|
assert!(pending_checked(&w.meta).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_modified_by_the_user_is_a_conflict_until_resolved() {
|
fn a_file_modified_by_the_user_is_a_conflict_until_resolved() {
|
||||||
let w = world(&[], false);
|
let w = world(&[], false);
|
||||||
@@ -1078,6 +1275,7 @@ mod tests {
|
|||||||
t.state = State::Applying;
|
t.state = State::Applying;
|
||||||
for op in t.ops.iter_mut() {
|
for op in t.ops.iter_mut() {
|
||||||
op.started = op.path == "Core/worldserver.exe";
|
op.started = op.path == "Core/worldserver.exe";
|
||||||
|
op.done &= op.started;
|
||||||
}
|
}
|
||||||
save(&w.meta, &t).unwrap();
|
save(&w.meta, &t).unwrap();
|
||||||
assert_eq!(unfinished(&w.meta).unwrap().id, id);
|
assert_eq!(unfinished(&w.meta).unwrap().id, id);
|
||||||
@@ -1177,6 +1375,79 @@ mod tests {
|
|||||||
assert!(unfinished(&w.meta).is_none());
|
assert!(unfinished(&w.meta).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_manifest_mismatch_is_rejected_before_snapshot_or_replacement() {
|
||||||
|
let w = world(&[], true);
|
||||||
|
let manifest_path = w.pkg.join("manifest.json");
|
||||||
|
let mut manifest: Manifest = fsx::read_json(&manifest_path).unwrap();
|
||||||
|
manifest.migrations[0].sha256 = "a".repeat(64);
|
||||||
|
let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
|
||||||
|
// A publisher mistake: valid signed archive bytes disagree with the SQL metadata.
|
||||||
|
let key = SigningKey::generate(&mut rand_core::OsRng);
|
||||||
|
let trusted = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes());
|
||||||
|
fs::write(&manifest_path, &bytes).unwrap();
|
||||||
|
fs::write(w.pkg.join("manifest.json.sig"), base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).to_bytes())).unwrap();
|
||||||
|
let env = Fake::ok();
|
||||||
|
let error = apply(&Params { root: &w.root, meta_dir: &w.meta, source: Source::Dir(w.pkg.clone()), trusted_key: &trusted,
|
||||||
|
cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None }, &|_, _| {}).unwrap_err();
|
||||||
|
assert!(matches!(error, Error::HashMismatch { .. }), "Expected SQL metadata mismatch, received {error}");
|
||||||
|
assert!(env.calls.borrow().is_empty());
|
||||||
|
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
|
||||||
|
assert!(pending_checked(&w.meta).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_rejection_precedes_snapshot_and_file_changes() {
|
||||||
|
let w = world(&[], true);
|
||||||
|
let env = Fake::ok();
|
||||||
|
env.preflight_fail.set(true);
|
||||||
|
let original = fsx::sha256_file(&w.meta.join("install.json")).unwrap();
|
||||||
|
assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("Conflicting migration history"));
|
||||||
|
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
|
||||||
|
assert_eq!(fsx::sha256_file(&w.meta.join("install.json")).unwrap(), original);
|
||||||
|
assert!(env.calls.borrow().is_empty());
|
||||||
|
assert!(fs::read_dir(w.meta.join("updates")).unwrap().next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_and_semantically_corrupt_journals_block_operations() {
|
||||||
|
let w = world(&[], false);
|
||||||
|
let txn = run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap().txn;
|
||||||
|
let path = txn_dir(&w.meta, &txn.id).unwrap().join("txn.json");
|
||||||
|
let mut bad = txn.clone();
|
||||||
|
bad.id = "another-update".into();
|
||||||
|
fsx::atomic_write_json(&path, &bad).unwrap();
|
||||||
|
assert!(pending_checked(&w.meta).is_err());
|
||||||
|
bad = txn.clone();
|
||||||
|
bad.ops[0].path = "../outside".into();
|
||||||
|
fsx::atomic_write_json(&path, &bad).unwrap();
|
||||||
|
assert!(ensure_recovered(&w.meta).is_err());
|
||||||
|
bad = txn.clone();
|
||||||
|
bad.ops[0].started = false;
|
||||||
|
fsx::atomic_write_json(&path, &bad).unwrap();
|
||||||
|
assert!(ensure_recovered(&w.meta).is_err());
|
||||||
|
fs::remove_file(&path).unwrap();
|
||||||
|
assert!(pending_checked(&w.meta).is_err());
|
||||||
|
let marker = cleanup_marker(&w.meta, &txn.id).unwrap();
|
||||||
|
fsx::atomic_write_json(&marker, &CleanupMarker { id: txn.id, state: State::Committed }).unwrap();
|
||||||
|
assert!(pending_checked(&w.meta).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damaged_saved_file_blocks_restore_before_any_environment_action() {
|
||||||
|
let w = world(&[], false);
|
||||||
|
let env = Fake::ok();
|
||||||
|
env.healthy.set(false);
|
||||||
|
let txn = run(&w, &env, BTreeMap::new(), None).unwrap().txn;
|
||||||
|
let before = txn_dir(&w.meta, &txn.id).unwrap().join("before");
|
||||||
|
write(&before, "Core/worldserver.exe", b"damaged original");
|
||||||
|
env.calls.borrow_mut().clear();
|
||||||
|
assert!(rollback(&w.root, &w.meta, &txn.id, &env).unwrap_err().to_string().contains("damaged"));
|
||||||
|
assert!(env.calls.borrow().is_empty());
|
||||||
|
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v2");
|
||||||
|
assert_eq!(pending_checked(&w.meta).unwrap().unwrap().state, State::NeedsDecision);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unreadable_update_journal_blocks_changes_and_startup() {
|
fn unreadable_update_journal_blocks_changes_and_startup() {
|
||||||
let w = world(&[], false);
|
let w = world(&[], false);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "coa-server-manager",
|
"name": "coa-server-manager",
|
||||||
"version": "0.6.4",
|
"version": "0.6.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "coa-server-manager",
|
"name": "coa-server-manager",
|
||||||
"version": "0.6.4",
|
"version": "0.6.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.12.0",
|
"@tauri-apps/api": "^2.12.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "coa-server-manager",
|
"name": "coa-server-manager",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.4",
|
"version": "0.6.5",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option<Str
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn pending_update(state: State<'_, AppState>, id: String) -> std::result::Result<Option<update::Txn>, UiError> {
|
fn pending_update(state: State<'_, AppState>, id: String) -> std::result::Result<Option<update::Txn>, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
Ok(update::unfinished(&meta_dir(&root)?))
|
Ok(update::pending_checked(&meta_dir(&root)?)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CoA Server Manager",
|
"productName": "CoA Server Manager",
|
||||||
"version": "0.6.4",
|
"version": "0.6.5",
|
||||||
"identifier": "com.coa.servermanager",
|
"identifier": "com.coa.servermanager",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ export {};
|
|||||||
if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};}
|
if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};}
|
||||||
if(cmd==="companion_sizes") return {hardware:{cores:16,ram_gb:31,free_ram_gb:12},sizes:[{id:"small",title:"Small",bots:50,warning:null},{id:"medium",title:"Medium",bots:250,warning:null},{id:"large",title:"Large",bots:500,warning:"500 companions may need more CPU and memory than this computer has (8 cores, 16 GB)."}]};
|
if(cmd==="companion_sizes") return {hardware:{cores:16,ram_gb:31,free_ram_gb:12},sizes:[{id:"small",title:"Small",bots:50,warning:null},{id:"medium",title:"Medium",bots:250,warning:null},{id:"large",title:"Large",bots:500,warning:"500 companions may need more CPU and memory than this computer has (8 cores, 16 GB)."}]};
|
||||||
if(cmd==="add_companions"){const base=(window as any).__bots??85;(window as any).__target=base+args.count;return {spawned:"queued",created:null,baseline:base};}
|
if(cmd==="add_companions"){const base=(window as any).__bots??85;(window as any).__target=base+args.count;return {spawned:"queued",created:null,baseline:base};}
|
||||||
if(cmd==="check_update") return {from_version:(window as any).__updated?"0.261001.8":"0.260930.5",to_version:"0.261001.8",items:[],conflicts:[],migrations:5,download_bytes:6000000};
|
if(cmd==="check_update") return {from_version:(window as any).__updated?"0.261001.8":"0.260930.5",to_version:"0.261001.8",items:[],conflicts:[],migrations:5,pending_migrations:(window as any).__updated?0:5,download_bytes:6000000};
|
||||||
if(cmd==="apply_update"){const w=window as any;for(let i=1;i<=8;i++){await new Promise((r: any)=>setTimeout(r,700));(w.__handlers?.["update-progress"]??[]).forEach((h: any)=>h({event:"update-progress",id:1,payload:{step:i<4?"Downloading the update":i<7?"Applying the update":"Finishing",percent:i*12}}));}(window as any).__updated=true;return {txn:{state:"committed",to_version:"0.261001.8"},migrations:null};}
|
if(cmd==="apply_update"){const w=window as any;for(let i=1;i<=8;i++){await new Promise((r: any)=>setTimeout(r,700));(w.__handlers?.["update-progress"]??[]).forEach((h: any)=>h({event:"update-progress",id:1,payload:{step:i<4?"Downloading the update":i<7?"Applying the update":"Finishing",percent:i*12}}));}(window as any).__updated=true;return {txn:{state:"committed",to_version:"0.261001.8"},migrations:null};}
|
||||||
if(cmd==="get_population"){if(!running) return null;let b=(window as any).__bots??85;const t=(window as any).__target??0;if(t>b){b=Math.min(t,b+25);(window as any).__bots=b;}return {online_total:b,bots_online:Math.max(0,b-7),players_online:0,bots_total:b};}
|
if(cmd==="get_population"){if(!running) return null;let b=(window as any).__bots??85;const t=(window as any).__target??0;if(t>b){b=Math.min(t,b+25);(window as any).__bots=b;}return {online_total:b,bots_online:Math.max(0,b-7),players_online:0,bots_total:b};}
|
||||||
if(cmd==="get_performance") return running?{mean_ms:14,median_ms:15,p95_ms:24,p99_ms:30,max_ms:61,ticks_per_sec:71.4}:null;
|
if(cmd==="get_performance") return running?{mean_ms:14,median_ms:15,p95_ms:24,p99_ms:30,max_ms:61,ticks_per_sec:71.4}:null;
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ export interface UpdatePreview {
|
|||||||
items: UpdatePlanItem[];
|
items: UpdatePlanItem[];
|
||||||
conflicts: string[];
|
conflicts: string[];
|
||||||
migrations: number;
|
migrations: number;
|
||||||
|
pending_migrations?: number;
|
||||||
download_bytes: number;
|
download_bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-5
@@ -1,6 +1,7 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api";
|
import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api";
|
||||||
|
import { isUpdateCurrent } from "./serverUpdateStatus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server update status for the main screen and the navigation: checked when the app starts and every five minutes,
|
* Server update status for the main screen and the navigation: checked when the app starts and every five minutes,
|
||||||
@@ -16,11 +17,13 @@ export interface ServerUpdateState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
/** The full error of the last update attempt, for the Settings card. */
|
/** The full error of the last update attempt, for the Settings card. */
|
||||||
uiError: UiError | null;
|
uiError: UiError | null;
|
||||||
|
stateError: UiError | null;
|
||||||
|
stateRevision: number;
|
||||||
/** How the last update ended; kept here so the Settings card can show it after the user left and came back. */
|
/** How the last update ended; kept here so the Settings card can show it after the user left and came back. */
|
||||||
result: { committed: string } | { pending: UpdateTxn } | null;
|
result: { committed: string } | { pending: UpdateTxn } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, result: null };
|
const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, stateError: null, stateRevision: 0, result: null };
|
||||||
const states = new Map<string, ServerUpdateState>();
|
const states = new Map<string, ServerUpdateState>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
export const CHECK_EVERY_MS = 5 * 60 * 1000;
|
export const CHECK_EVERY_MS = 5 * 60 * 1000;
|
||||||
@@ -37,10 +40,10 @@ export async function checkServerUpdate(id: string): Promise<void> {
|
|||||||
set(id, { checking: true });
|
set(id, { checking: true });
|
||||||
try {
|
try {
|
||||||
const p = await api.checkUpdate(id);
|
const p = await api.checkUpdate(id);
|
||||||
set(id, { preview: p, available: p.to_version !== (p.from_version ?? ""), error: null });
|
set(id, { preview: p, available: !isUpdateCurrent(p), error: null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// offline or no package published: keep what we knew and stay quiet
|
// A cached offer is no longer safe after an update or failed recheck.
|
||||||
set(id, { error: asUiError(e).human.code });
|
set(id, { preview: null, available: false, error: asUiError(e).human.code });
|
||||||
} finally {
|
} finally {
|
||||||
set(id, { checking: false });
|
set(id, { checking: false });
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,20 @@ export function clearServerUpdateResult(id: string) {
|
|||||||
set(id, { result: null, uiError: null });
|
set(id, { result: null, uiError: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshServerUpdateState(id: string): Promise<UpdateTxn | null> {
|
||||||
|
try {
|
||||||
|
const pending = await api.pendingUpdate(id);
|
||||||
|
const current = get(id).result;
|
||||||
|
set(id, { stateError: null, stateRevision: get(id).stateRevision + 1,
|
||||||
|
...(pending ? { available: false, preview: null } : {}),
|
||||||
|
result: pending ? { pending } : current && "committed" in current ? current : null });
|
||||||
|
return pending;
|
||||||
|
} catch (e) {
|
||||||
|
set(id, { stateError: asUiError(e), stateRevision: get(id).stateRevision + 1, available: false, preview: null });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply an update. Without options this is the main-screen button: the pending update, only when no file needs a
|
* Apply an update. Without options this is the main-screen button: the pending update, only when no file needs a
|
||||||
* decision. With options (the Settings card) the owner has already chosen what to do with each changed file and may
|
* decision. With options (the Settings card) the owner has already chosen what to do with each changed file and may
|
||||||
@@ -65,7 +82,8 @@ export function clearServerUpdateResult(id: string) {
|
|||||||
*/
|
*/
|
||||||
export async function applyServerUpdate(id: string, opts?: { choices: Record<string, "keep" | "replace">; source?: string }): Promise<boolean> {
|
export async function applyServerUpdate(id: string, opts?: { choices: Record<string, "keep" | "replace">; source?: string }): Promise<boolean> {
|
||||||
const p = get(id).preview;
|
const p = get(id).preview;
|
||||||
if (get(id).applying) return false;
|
const current = get(id);
|
||||||
|
if (current.applying || current.stateError || (current.result && "pending" in current.result)) return false;
|
||||||
if (!opts && (!p || p.conflicts.length > 0)) return false;
|
if (!opts && (!p || p.conflicts.length > 0)) return false;
|
||||||
set(id, { applying: true, step: null, percent: 0, error: null, uiError: null, result: null });
|
set(id, { applying: true, step: null, percent: 0, error: null, uiError: null, result: null });
|
||||||
let un: (() => void) | undefined;
|
let un: (() => void) | undefined;
|
||||||
@@ -84,6 +102,9 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record<str
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
un?.();
|
un?.();
|
||||||
|
try {
|
||||||
|
await refreshServerUpdateState(id);
|
||||||
|
} catch { /* The state error remains visible and prevents another apply. */ }
|
||||||
set(id, { applying: false });
|
set(id, { applying: false });
|
||||||
void checkServerUpdate(id);
|
void checkServerUpdate(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { UpdatePreview } from "./api";
|
||||||
|
|
||||||
|
export function isUpdateCurrent(preview: UpdatePreview): boolean {
|
||||||
|
return preview.from_version === preview.to_version
|
||||||
|
&& preview.items.every((item) => item.action === "skip")
|
||||||
|
&& (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import { applyServerUpdate, clearServerUpdateResult, useServerUpdate } from "@/lib/serverUpdate";
|
import { applyServerUpdate, clearServerUpdateResult, refreshServerUpdateState, useServerUpdate } from "@/lib/serverUpdate";
|
||||||
|
import { isUpdateCurrent } from "@/lib/serverUpdateStatus";
|
||||||
import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api";
|
import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -23,12 +24,14 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
const human = useHuman();
|
const human = useHuman();
|
||||||
const [preview, setPreview] = useState<UpdatePreview | null>(null);
|
const [preview, setPreview] = useState<UpdatePreview | null>(null);
|
||||||
const [pending, setPending] = useState<UpdateTxn | null>(null);
|
const [pending, setPending] = useState<UpdateTxn | null>(null);
|
||||||
|
const [pendingChecked, setPendingChecked] = useState(false);
|
||||||
|
const [pendingStateError, setPendingStateError] = useState<UiError | null>(null);
|
||||||
const [localBusy, setBusy] = useState<"check" | "rollback" | "retry" | null>(null);
|
const [localBusy, setBusy] = useState<"check" | "rollback" | "retry" | null>(null);
|
||||||
// an update keeps running in the background when this tab is left, so its state is the shared one
|
// an update keeps running in the background when this tab is left, so its state is the shared one
|
||||||
const busy = upd.applying ? "update" : localBusy;
|
const busy = upd.applying ? "update" : localBusy;
|
||||||
const progress = upd.applying ? { step: upd.step ?? "Starting", percent: upd.percent } : null;
|
const progress = upd.applying ? { step: upd.step ?? "Starting", percent: upd.percent } : null;
|
||||||
const [localError, setError] = useState<UiError | null>(null);
|
const [localError, setError] = useState<UiError | null>(null);
|
||||||
const error = localError ?? upd.uiError;
|
const error = pendingStateError ?? localError ?? upd.uiError;
|
||||||
const [localDone, setDone] = useState<string | null>(null);
|
const [localDone, setDone] = useState<string | null>(null);
|
||||||
const done = upd.result && "committed" in upd.result ? t("upd.updatedTo", { v: upd.result.committed }) : localDone;
|
const done = upd.result && "committed" in upd.result ? t("upd.updatedTo", { v: upd.result.committed }) : localDone;
|
||||||
const [choices, setChoices] = useState<Record<string, "keep" | "replace">>({});
|
const [choices, setChoices] = useState<Record<string, "keep" | "replace">>({});
|
||||||
@@ -36,16 +39,36 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.pendingUpdate(serverId).then(setPending).catch(() => {});
|
let active = true;
|
||||||
|
setPendingChecked(false);
|
||||||
|
setPending(null);
|
||||||
|
setPreview(null);
|
||||||
|
setPendingStateError(null);
|
||||||
|
void api.pendingUpdate(serverId).then((txn) => {
|
||||||
|
if (active) { setPending(txn); setPendingChecked(true); }
|
||||||
|
}).catch((e) => { if (active) setPendingStateError(asUiError(e)); });
|
||||||
|
return () => { active = false; };
|
||||||
}, [serverId]);
|
}, [serverId]);
|
||||||
|
|
||||||
// the update finished (possibly while another tab was open): drop the stale preview, show what is left to decide
|
// Every finished attempt refreshes the disk state, including failures in the background.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!upd.result) return;
|
if (!upd.stateRevision) return;
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
if ("pending" in upd.result) setPending(upd.result.pending);
|
setPendingChecked(!upd.stateError);
|
||||||
else void api.pendingUpdate(serverId).then(setPending).catch(() => {});
|
setPendingStateError(upd.stateError);
|
||||||
}, [upd.result, serverId]);
|
if (!upd.stateError) setPending(upd.result && "pending" in upd.result ? upd.result.pending : null);
|
||||||
|
}, [upd.stateRevision, serverId]);
|
||||||
|
|
||||||
|
async function refreshPending() {
|
||||||
|
setPendingChecked(false);
|
||||||
|
try {
|
||||||
|
const txn = await refreshServerUpdateState(serverId);
|
||||||
|
setPending(txn);
|
||||||
|
setPendingStateError(null);
|
||||||
|
setPendingChecked(true);
|
||||||
|
return txn;
|
||||||
|
} catch (e) { setPendingStateError(asUiError(e)); throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
async function check() {
|
async function check() {
|
||||||
setBusy("check");
|
setBusy("check");
|
||||||
@@ -54,6 +77,8 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
clearServerUpdateResult(serverId);
|
clearServerUpdateResult(serverId);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
try {
|
try {
|
||||||
|
const txn = await refreshPending();
|
||||||
|
if (txn) return;
|
||||||
const p = await api.checkUpdate(serverId, source.trim() || undefined);
|
const p = await api.checkUpdate(serverId, source.trim() || undefined);
|
||||||
setPreview(p);
|
setPreview(p);
|
||||||
setChoices(Object.fromEntries(p.conflicts.map((c) => [c, "keep" as const])));
|
setChoices(Object.fromEntries(p.conflicts.map((c) => [c, "keep" as const])));
|
||||||
@@ -65,6 +90,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function update() {
|
async function update() {
|
||||||
|
if (!pendingChecked || pendingStateError || pending) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setDone(null);
|
setDone(null);
|
||||||
await applyServerUpdate(serverId, { choices, source: source.trim() || undefined });
|
await applyServerUpdate(serverId, { choices, source: source.trim() || undefined });
|
||||||
@@ -81,6 +107,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(asUiError(e));
|
setError(asUiError(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
try { await refreshPending(); } catch { /* Keep the disk-state error visible. */ }
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +124,10 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
clearServerUpdateResult(serverId);
|
clearServerUpdateResult(serverId);
|
||||||
} else setPending(result);
|
} else setPending(result);
|
||||||
} catch (e) { setError(asUiError(e)); }
|
} catch (e) { setError(asUiError(e)); }
|
||||||
finally { setBusy(null); }
|
finally {
|
||||||
|
try { await refreshPending(); } catch { /* Keep the disk-state error visible. */ }
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function browse() {
|
async function browse() {
|
||||||
@@ -165,28 +195,27 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{done && <p className="mt-4 text-sm text-ok" role="status">{done}</p>}
|
{done && pendingChecked && !pendingStateError && <p className="mt-4 text-sm text-ok" role="status">{done}</p>}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-4 text-sm text-bad" role="alert">
|
<p className="mt-4 text-sm text-bad" role="alert">
|
||||||
{error.human.code === "unknown" ? error.technical : human(error.human).message}
|
{error.human.code === "unknown" ? error.technical : human(error.human).message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{preview && preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip") && (
|
{preview && isUpdateCurrent(preview) && (
|
||||||
// The same version with no file to change: the database scripts of this package are all in the ledger already.
|
|
||||||
<div className="mt-5 border-t border-line pt-4">
|
<div className="mt-5 border-t border-line pt-4">
|
||||||
<p className="font-medium text-ok" role="status">{t("upd.current", { v: preview.to_version })}</p>
|
<p className="font-medium text-ok" role="status">{t("upd.current", { v: preview.to_version })}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{preview && !(preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip")) && (
|
{preview && !isUpdateCurrent(preview) && (
|
||||||
<div className="mt-5 border-t border-line pt-4">
|
<div className="mt-5 border-t border-line pt-4">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}
|
{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-muted">
|
<p className="mt-1 text-sm text-muted">
|
||||||
{preview.migrations > 0
|
{(preview.pending_migrations ?? preview.migrations) > 0
|
||||||
? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.migrations })
|
? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations })
|
||||||
: t("upd.summary", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length })}
|
: t("upd.summary", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length })}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 max-h-56 divide-y divide-line overflow-auto text-sm">
|
<ul className="mt-3 max-h-56 divide-y divide-line overflow-auto text-sm">
|
||||||
@@ -211,7 +240,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Button className="mt-4" variant="primary" disabled={!!busy} onClick={() => void update()}>
|
<Button className="mt-4" variant="primary" disabled={!!busy || !pendingChecked || !!pendingStateError || !!pending} onClick={() => void update()}>
|
||||||
{busy === "update" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
{busy === "update" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||||
{t("upd.now")}
|
{t("upd.now")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { registerHooks } from "node:module";
|
||||||
|
|
||||||
|
let applyFailure = false;
|
||||||
|
let stateFailure = false;
|
||||||
|
let pending = null;
|
||||||
|
const calls = [];
|
||||||
|
globalThis.__hotfixApi = {
|
||||||
|
applyUpdate: async () => { calls.push("apply"); if (applyFailure) throw new Error("SQL failure"); return { txn: { state: "committed", to_version: "2" } }; },
|
||||||
|
pendingUpdate: async () => { calls.push("disk"); if (stateFailure) throw new Error("damaged journal"); return pending; },
|
||||||
|
checkUpdate: async () => ({ from_version: "2", to_version: "2", items: [], conflicts: [], migrations: 1, pending_migrations: 0 }),
|
||||||
|
};
|
||||||
|
registerHooks({ resolve(specifier, context, next) {
|
||||||
|
if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();" };
|
||||||
|
if (specifier === "@/lib/api") return { shortCircuit: true, url: "data:text/javascript,export const api=globalThis.__hotfixApi;export const asUiError=e=>({human:{code:'unknown'},technical:e.message});" };
|
||||||
|
if (specifier === "@tauri-apps/api/event") return { shortCircuit: true, url: "data:text/javascript,export const listen=async()=>()=>{};" };
|
||||||
|
if (specifier === "./serverUpdateStatus" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/serverUpdateStatus.ts", import.meta.url).href };
|
||||||
|
return next(specifier, context);
|
||||||
|
} });
|
||||||
|
const { applyServerUpdate, refreshServerUpdateState, checkServerUpdate, useServerUpdate } = await import("../src/lib/serverUpdate.ts");
|
||||||
|
|
||||||
|
test("a failed update rereads its unfinished transaction from disk", async () => {
|
||||||
|
calls.length = 0;
|
||||||
|
applyFailure = true;
|
||||||
|
pending = { id: "interrupted", state: "failed", message: "Recovery required" };
|
||||||
|
assert.equal(await applyServerUpdate("failed-case", { choices: {} }), false);
|
||||||
|
assert.deepEqual(calls, ["apply", "disk"]);
|
||||||
|
assert.equal(await refreshServerUpdateState("failed-case"), pending);
|
||||||
|
});
|
||||||
|
test("unreadable state blocks another apply until a successful reread", async () => {
|
||||||
|
calls.length = 0;
|
||||||
|
applyFailure = false;
|
||||||
|
stateFailure = true;
|
||||||
|
await applyServerUpdate("corrupt-case", { choices: {} });
|
||||||
|
assert.equal(await applyServerUpdate("corrupt-case", { choices: {} }), false);
|
||||||
|
assert.deepEqual(calls, ["apply", "disk"]);
|
||||||
|
stateFailure = false;
|
||||||
|
pending = null;
|
||||||
|
assert.equal(await refreshServerUpdateState("corrupt-case"), null);
|
||||||
|
assert.equal(await applyServerUpdate("corrupt-case", { choices: {} }), true);
|
||||||
|
assert.deepEqual(calls, ["apply", "disk", "disk", "apply", "disk"]);
|
||||||
|
});
|
||||||
|
test("refresh after recovery returns the latest disk state, then its absence", async () => {
|
||||||
|
pending = { id: "recovery", state: "failed", message: "Restore failed at database verification" };
|
||||||
|
assert.equal(await refreshServerUpdateState("recovery-case"), pending);
|
||||||
|
pending = null;
|
||||||
|
assert.equal(await refreshServerUpdateState("recovery-case"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected recheck removes an earlier cached update offer", async () => {
|
||||||
|
const original = globalThis.__hotfixApi.checkUpdate;
|
||||||
|
try {
|
||||||
|
globalThis.__hotfixApi.checkUpdate = async () => ({ from_version: "0.261005.22", to_version: "0.261005.24", items: [], migrations: 1, pending_migrations: 1 });
|
||||||
|
await checkServerUpdate("stale-offer");
|
||||||
|
assert.equal(useServerUpdate("stale-offer").available, true);
|
||||||
|
globalThis.__hotfixApi.checkUpdate = async () => { throw new Error("Installed version is newer than package"); };
|
||||||
|
await checkServerUpdate("stale-offer");
|
||||||
|
assert.equal(useServerUpdate("stale-offer").available, false);
|
||||||
|
assert.equal(useServerUpdate("stale-offer").preview, null);
|
||||||
|
} finally { globalThis.__hotfixApi.checkUpdate = original; }
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isUpdateCurrent } from "../src/lib/serverUpdateStatus.ts";
|
||||||
|
|
||||||
|
const same = { from_version: "1", to_version: "1", items: [{ action: "skip" }], migrations: 10 };
|
||||||
|
|
||||||
|
test("same files and version cannot hide pending database repairs", () => {
|
||||||
|
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 1 }), false);
|
||||||
|
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0 }), true);
|
||||||
|
});
|
||||||
|
test("unknown history is not presented as up to date", () => {
|
||||||
|
assert.equal(isUpdateCurrent(same), false);
|
||||||
|
assert.equal(isUpdateCurrent({ ...same, migrations: 0 }), true);
|
||||||
|
});
|
||||||
|
test("file changes and new releases remain available with no pending SQL", () => {
|
||||||
|
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0, items: [{ action: "replace" }] }), false);
|
||||||
|
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0, to_version: "2" }), false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user