mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Merge current master into portable characters while retaining 0.7.0
This commit is contained in:
commit
f40c0e1324
114 files changed
+6694
-600
No files matched your search
@@ -7,3 +7,5 @@ src-tauri/gen
|
|||||||
|
|
||||||
# standalone service workspaces build into their own target folders
|
# standalone service workspaces build into their own target folders
|
||||||
services/*/target/
|
services/*/target/
|
||||||
|
# Disposable database fixtures and private recovery data
|
||||||
|
.cache/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: admins
|
||||||
|
title: Linux Docker servers follow Settings, sharing and backups (experimental)
|
||||||
|
---
|
||||||
|
Experimental. Settings the Docker backend fixes are hidden, friends sharing and logs work, the game data folder can be changed, and the realm no longer shows offline after a start.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: Failed server startup after an update automatically restores the previous version
|
||||||
|
---
|
||||||
|
The Manager restores server files and databases, then checks the recovered server. If recovery cannot be confirmed, it preserves the recovery point and blocks further startup until the problem is resolved.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Windows update recovery preserves database procedures triggers and views
|
||||||
|
---
|
||||||
|
A verified copy of stopped MySQL restores all databases and accounts together. Interrupted restoration can be retried, and the replaced database directory is retained.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Custom races bot setting synchronizes Playerbots excluded race lists
|
||||||
|
---
|
||||||
|
The "Bots can be custom races" setting now configures AiPlayerbot.ExcludedBotRaces in playerbots.conf. Off excludes all added races (recommended), while on allows stable added races (experimental).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Blocked updates identify database objects that prevent automatic recovery
|
||||||
|
---
|
||||||
|
Diagnostics include the complete list of unsupported database objects across the recovery point, helping resolve update failures without removing user data.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: admins
|
||||||
|
title: Updates can follow signed channels that select a complete server release
|
||||||
|
---
|
||||||
|
The Manager checks the channel signature and downloads every package part from the same release. Existing installations continue using the previous download source until the new channel is published.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Server updates check startup privately before allowing players to connect
|
||||||
|
---
|
||||||
|
Startup checks use temporary local ports. Original settings are restored before opening the server, and interrupted checks retain a recovery journal.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Local hosts can connect while sharing a server online
|
||||||
|
---
|
||||||
|
The Manager no longer replaces the realm's local address with its public address when Friends mode is enabled.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Updates repair missing database defaults from the verified release schema
|
||||||
|
---
|
||||||
|
Only missing literal defaults are restored when column properties match. Existing rows, custom defaults, types and nullability are preserved; unresolved incompatibilities still block the update.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Server updates no longer replay module installation scripts on existing databases
|
||||||
|
---
|
||||||
|
Update packages contain incremental SQL only. Module table initialization and seed scripts remain available when preparing a clean installation, preventing existing content from being reset during updates.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: admins
|
||||||
|
title: Windows server updates are tested on a private copy before changing your installation
|
||||||
|
---
|
||||||
|
The Manager tests migrations and server startup using your databases and configurations. A failed rehearsal leaves the installed server unchanged and preserves a diagnostic report.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: admins
|
||||||
|
title: Server updates can require a tested combination of platform and installed version
|
||||||
|
---
|
||||||
|
Signed release metadata identifies core and module revisions, supported starting versions and database contracts. Incompatible packages are rejected before shutdown; changed schema contracts and incompatible databases block installation before migration.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Failed database checks keep a complete report after rollback
|
||||||
|
---
|
||||||
|
Schema errors show readable default values and save every detected difference for troubleshooting, including after the server is restored.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: players
|
||||||
|
title: Players can install custom race patches when connecting to a friend
|
||||||
|
---
|
||||||
|
Install the custom race client patch directly from the Game client section without hosting a server.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Large server updates no longer repeatedly rewrite the recovery journal
|
||||||
|
---
|
||||||
|
Updates containing rebuilt navigation data now prepare all recovery copies before replacing files. Interrupted updates remain recoverable without rewriting the full journal for every file.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Normalize rehearsal server roots to prevent Windows path prefix errors
|
||||||
|
---
|
||||||
|
Server update rehearsals now canonicalize the installation root before collecting files, resolving a "prefix not found" error during rehearsal preparation on Windows.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: client
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: Custom race client installation follows the current upstream release
|
||||||
|
---
|
||||||
|
The installer verifies the current race archives and includes the two additional MPQs. Legacy patch hashes remain available so upgrading never mistakes the old custom executable for an original launcher file.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Docker friend mode requests a restart when remote client support changes
|
||||||
|
---
|
||||||
|
Enabling remote CoA clients now requests a world restart even when the game ports were already open. Repeating the same unchanged setting does not request another restart.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: admins
|
||||||
|
title: Module cards show installed server revisions and the client race patch version
|
||||||
|
---
|
||||||
|
Module revisions come from the currently installed release metadata. The race card shows the client patch version separately; missing version metadata is reported honestly instead of showing a download version as installed.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Server updates accept long Windows paths inside the private test server
|
||||||
|
---
|
||||||
|
Long SQL file paths no longer fail the folder safety check during update rehearsals. Files outside the server folder remain blocked.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Diagnostics record blocked startup requests and maintenance owner information
|
||||||
|
---
|
||||||
|
A start refused before the launcher runs is now logged with its reason. Exported diagnostics include the last recorded maintenance owner, while clearly distinguishing old metadata from a currently held operating-system lock.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Client race patches can be repaired and removed while preserving original files
|
||||||
|
---
|
||||||
|
Every overwritten client file gets an original backup. Switching preserves newer launcher files; checksums detect missing or replaced assets. Repair and removal are always available, with rollback if a file operation fails.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Checking an already installed server update no longer reports incompatibility
|
||||||
|
---
|
||||||
|
The installed release can be checked again even when its compatibility list only names earlier releases. Platform checks and protection against downgrades still apply.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Failed server startup shows service errors even when the launcher reported ready
|
||||||
|
---
|
||||||
|
Update rehearsal and startup failures include recent world, authentication and database error lines. Missing tables and late startup failures are visible directly in the error details, with secrets redacted.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: bots
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Disabling custom races for SQUID bots now excludes all nonstandard races
|
||||||
|
---
|
||||||
|
The standard-races preset keeps only the ten original playable races. High elves, worgen and other custom race IDs are excluded, and the displayed setting reads complete race IDs rather than matching number substrings.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Starting the server waits for update checks and identifies ongoing maintenance
|
||||||
|
---
|
||||||
|
Downloading an update manifest no longer blocks startup. A start waits up to 30 seconds for a check to finish; other active maintenance still blocks it safely and shows its operation, process and start time.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: bots
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: The experimental SQUID race preset follows the current race release
|
||||||
|
---
|
||||||
|
Custom-race bots now use the upstream supported-race preset, excluding Test Race, Skyborne and Darkfallen. The standard-races preset continues to exclude all custom races.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Already running MySQL is recognized before startup readiness checks
|
||||||
|
---
|
||||||
|
A verified MySQL listener replaces a stale or supervisor-only launcher record before startup. Existing databases are reused instead of waiting for a port owned by a different process from the same executable.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: New servers install the current stable build directly
|
||||||
|
---
|
||||||
|
A clean installation downloads the complete package of the selected stable release, including its prepared database. It no longer installs an old base and asks for a separate update. Missing or mismatched full packages stop installation before changing the destination.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: changed
|
||||||
|
audience: players
|
||||||
|
title: Install the matching client patches for Grummle
|
||||||
|
---
|
||||||
|
Custom race installation includes the new Grummle appearance and voices while retaining the existing race assets. Older installations can still be safely switched off or removed.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Failed repairs restore the previous server after final verification errors
|
||||||
|
---
|
||||||
|
Errors after file replacement now undo the repair using its verified recovery point, including final file or database check errors. If recovery fails, the journal retains the cause and further server actions remain blocked.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Failed server updates show an error on the Overview screen
|
||||||
|
---
|
||||||
|
The error stays visible after the progress bar disappears, with technical details and a copy button. Recovery-state errors are shown there too.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: Private update checks show preparation stages and stop failure details
|
||||||
|
---
|
||||||
|
Private-copy preparation reports checked and copied file counts, database tests and startup stages. Failed shutdown reports the server folder, remaining processes and launcher reason. Port conflicts identify the owning process and executable.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Repair recovery copies preserve all bundled database objects and accounts
|
||||||
|
---
|
||||||
|
Windows repairs now save a verified copy of stopped MySQL before changing server files or SQL. Recovery preserves procedures, triggers, views and database accounts as well as player rows.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Server checks report interrupted SQUID database imports
|
||||||
|
---
|
||||||
|
Check and Repair now report SQUID pending imports and missing bot character tables separately from package migrations. Interrupted imports retain their records and require verified database recovery.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Shutdown remains available when startup templates or database records are damaged
|
||||||
|
---
|
||||||
|
Stop uses existing shutdown settings instead of rendering startup templates. Missing or stale MySQL records are recovered only from the exact installed executable and its owned port; foreign processes are never adopted.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Successful server repairs no longer block later server actions
|
||||||
|
---
|
||||||
|
Repair verifies restored files before completing its recovery record. Older completed repair records are accepted only when every repaired file still matches its expected content.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: Server updates apply migrations once after saving a recovery point
|
||||||
|
---
|
||||||
|
Updates no longer build and start a second full server copy. The Manager checks both realms, saves recovery, applies pending SQL once and verifies startup. Failed SQL or startup restores the recovery point. Full-copy upgrade testing remains a release task.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: changed
|
||||||
|
audience: admins
|
||||||
|
title: Older server versions update directly without installing intermediate packages
|
||||||
|
---
|
||||||
|
The Manager checks database history and applies missing SQL from the latest signed package. A version missing from the release test list no longer blocks the update. Unknown versions, interrupted migrations and downgrades remain blocked.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Update validation recognizes an already stopped server
|
||||||
|
---
|
||||||
|
Validation uses the same complete-server stop check as recovery instead of issuing redundant unconditional shutdown commands.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Older servers find a tested intermediate update instead of being blocked
|
||||||
|
---
|
||||||
|
When the latest release cannot update an older server directly, Manager offers a signed intermediate release from its compatibility matrix and explains how to continue to the latest version.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Update checks no longer download a server archive to compare its launcher
|
||||||
|
---
|
||||||
|
Manager recognizes its supported launcher edits against the signed file hash locally. Overlapping checks wait briefly for one another, and the private update copy checks combined program and database space before copying.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Recorded core SQL stays applied after old history tables are cleaned up
|
||||||
|
---
|
||||||
|
Existing core migration records are preserved in Manager history. Unknown legacy checksums remain explicitly unknown, and interrupted or changed migrations are still rejected.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Switching realms no longer repeats shared account database updates
|
||||||
|
---
|
||||||
|
Solo and Wildcard use one migration history for their shared account database. Previously applied records from either realm are recognized without running their SQL again.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: fixed
|
||||||
|
audience: admins
|
||||||
|
title: Update offers distinguish version support from database validation
|
||||||
|
---
|
||||||
|
An available version sequence no longer claims that every database state has been tested. Database compatibility is checked during the update.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Read-only audit, or explicit SQL checks on a disposable database fixture.
|
||||||
|
use coa_core::{db::{Account, Db}, migrations::{self, Store}, manifest::Manifest, realms::Mode};
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args: Vec<_> = std::env::args().collect();
|
||||||
|
if args.len() < 3 { return Err("usage: migration_plan <root> <signed-manifest> [--apply|--sql-only <migration-directory>]".into()); }
|
||||||
|
let root = std::path::Path::new(&args[1]);
|
||||||
|
let bytes = std::fs::read(&args[2])?;
|
||||||
|
let signature = std::fs::read_to_string(format!("{}.sig",args[2]))?;
|
||||||
|
coa_core::signing::verify(&bytes, &signature, coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||||
|
let manifest = Manifest::parse(&bytes)?;
|
||||||
|
for (index,mode) in [Mode::Coa, Mode::Wildcard].into_iter().enumerate() {
|
||||||
|
let db = Db::from_repack(root, Account::Admin)?.for_realm(mode);
|
||||||
|
let rows = db.load_readonly()?;
|
||||||
|
let relevant: Vec<_> = rows.iter().filter(|r| r.id == "rev_20260903_01_live_class_baseline").map(|r| format!("{} {:?} {}",r.db,r.status,r.baseline)).collect();
|
||||||
|
println!("{} baseline history: {:?}",mode.name(),relevant);
|
||||||
|
let list: Vec<_> = manifest.migrations.iter().filter(|m|index==0 || m.db!="auth").cloned().collect();
|
||||||
|
let plan = migrations::status(&db,&list)?;
|
||||||
|
println!("{} pending: {:?}",mode.name(),plan.iter().filter(|m|m.status!=migrations::Status::Applied).map(|m|(&m.db,&m.id,m.status)).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
if args.get(3).is_some_and(|s| s == "--apply" || s == "--sql-only") {
|
||||||
|
assert!(root.file_name().unwrap().to_string_lossy().starts_with("update-database-diagnosis-") && root.join(".coa-update-fixture").is_file(), "Only disposable fixture roots may be changed");
|
||||||
|
if args[3] == "--sql-only" {
|
||||||
|
let db = Db::from_repack(root, Account::Admin)?.for_realm(Mode::Coa);
|
||||||
|
let identity = || -> coa_core::Result<String> {
|
||||||
|
let mut rows = String::new();
|
||||||
|
for (schema,table,columns) in [("acore_auth","account","id,HEX(username)"),("acore_characters","characters","guid,account,HEX(name)"),("acore_characters_wildcard","characters","guid,account,HEX(name)")] {
|
||||||
|
rows.push_str(schema);
|
||||||
|
rows.push_str(&db.query(&format!("SELECT {columns} FROM {schema}.{table} ORDER BY 1;"))?);
|
||||||
|
}
|
||||||
|
Ok(coa_core::fsx::sha256_bytes(rows.as_bytes()))
|
||||||
|
};
|
||||||
|
let before = identity()?;
|
||||||
|
migrations::verify_files(&manifest.migrations,std::path::Path::new(&args[4]))?;
|
||||||
|
for (index,mode) in [Mode::Coa,Mode::Wildcard].into_iter().enumerate() {
|
||||||
|
let realm_db = db.clone().for_realm(mode);
|
||||||
|
let list: Vec<_> = manifest.migrations.iter().filter(|m|index==0 || m.db!="auth").cloned().collect();
|
||||||
|
let result = migrations::apply_pending(&realm_db,&list,std::path::Path::new(&args[4]),&||Ok("disposable fixture".into()))?;
|
||||||
|
println!("{} SQL result: {}",mode.name(),serde_json::to_string(&result)?);
|
||||||
|
if result.failed.is_some() {return Err("SQL failed; fixture preserved".into());}
|
||||||
|
}
|
||||||
|
assert_eq!(before,identity()?,"Account or character identity changed");
|
||||||
|
println!("Account and character identities preserved: {}",before);
|
||||||
|
let capture = root.join("captured-schema");
|
||||||
|
coa_core::schema_check::capture(&db,&capture)?;
|
||||||
|
println!("Captured schema SHA256: {}",coa_core::fsx::sha256_file(&capture.join(coa_core::schema_check::CONTRACT))?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if args.len() != 5 { return Err("Migration directory required".into()); }
|
||||||
|
use coa_core::update::{Env, RepackEnv};
|
||||||
|
let meta = coa_core::registry::metadata_dir_for(root)?;
|
||||||
|
let env = RepackEnv { root, meta_dir: &meta };
|
||||||
|
let result = env.migrate(&manifest, std::path::Path::new(&args[4]));
|
||||||
|
let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll);
|
||||||
|
println!("Application result: {}",serde_json::to_string(&result?)?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ fn exercise(root: &Path) -> Result<()> {
|
|||||||
MetaDir::create(root, &installed)?;
|
MetaDir::create(root, &installed)?;
|
||||||
backup::with_database(root, |db| {
|
backup::with_database(root, |db| {
|
||||||
db.query("CREATE TABLE acore_characters.manager_recovery_probe (id INT); INSERT INTO acore_characters.manager_recovery_probe VALUES (42); CREATE TABLE acore_world.manager_recovery_probe (id INT); INSERT INTO acore_world.manager_recovery_probe VALUES (43);")?;
|
db.query("CREATE TABLE acore_characters.manager_recovery_probe (id INT); INSERT INTO acore_characters.manager_recovery_probe VALUES (42); CREATE TABLE acore_world.manager_recovery_probe (id INT); INSERT INTO acore_world.manager_recovery_probe VALUES (43);")?;
|
||||||
|
db.query("CREATE VIEW acore_characters.manager_recovery_view AS SELECT id FROM acore_characters.manager_recovery_probe; CREATE TRIGGER acore_characters.manager_recovery_trigger BEFORE INSERT ON acore_characters.manager_recovery_probe FOR EACH ROW SET NEW.id=NEW.id+1; CREATE PROCEDURE acore_characters.manager_recovery_proc() SELECT id FROM acore_characters.manager_recovery_probe;")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
let source = root.join("fixture-update-source");
|
let source = root.join("fixture-update-source");
|
||||||
@@ -118,7 +119,12 @@ fn exercise(root: &Path) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("probe_world"), "{error}");
|
assert!(error.to_string().contains("probe_world"), "{error}");
|
||||||
assert!(error.to_string().contains("were restored"), "{error}");
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("private update rehearsal failed"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
assert!(!root.join("Core/recovery-probe.txt").exists());
|
assert!(!root.join("Core/recovery-probe.txt").exists());
|
||||||
assert!(update::unfinished(&meta).is_none());
|
assert!(update::unfinished(&meta).is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -134,6 +140,19 @@ fn exercise(root: &Path) -> Result<()> {
|
|||||||
db.query("SELECT id FROM acore_world.manager_recovery_probe;")?,
|
db.query("SELECT id FROM acore_world.manager_recovery_probe;")?,
|
||||||
"43"
|
"43"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.query("SELECT id FROM acore_characters.manager_recovery_view;")?,
|
||||||
|
"42"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.recovery_objects("acore_characters")?
|
||||||
|
.iter()
|
||||||
|
.filter(|object| object["name"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|name| name.starts_with("manager_recovery_")))
|
||||||
|
.count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
assert!(!coa_core::migrations::status(db, &manifest.migrations)?
|
assert!(!coa_core::migrations::status(db, &manifest.migrations)?
|
||||||
.iter()
|
.iter()
|
||||||
.any(|m| m.status == coa_core::migrations::Status::Applied));
|
.any(|m| m.status == coa_core::migrations::Status::Applied));
|
||||||
@@ -149,6 +168,6 @@ fn exercise(root: &Path) -> Result<()> {
|
|||||||
Db::from_repack(root, Account::Admin)?.realm(),
|
Db::from_repack(root, Account::Admin)?.realm(),
|
||||||
coa_core::realms::Mode::Coa
|
coa_core::realms::Mode::Coa
|
||||||
);
|
);
|
||||||
println!("PASS: failed SQL restored world, characters, ledger, files and version; repeated recovery succeeded");
|
println!("PASS: private rehearsal caught failed SQL before changing the installed world, characters, ledger, objects, files or version; complete recovery succeeded");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Exercise the production updater only on a disposable copy of an installed server.
|
||||||
|
use coa_core::{registry::MetaDir, update::{self, Env, RepackEnv}};
|
||||||
|
fn main() -> Result<(),Box<dyn std::error::Error>> {
|
||||||
|
let args:Vec<_>=std::env::args().collect();
|
||||||
|
if args.len()!=3 && args.len()!=4 {return Err("usage: upgrade_fixture <disposable-root> <signed-package-directory>".into());}
|
||||||
|
let root=std::path::Path::new(&args[1]);
|
||||||
|
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-real") && root.join(".coa-update-fixture").is_file(),"Disposable fixture required");
|
||||||
|
let meta=coa_core::registry::metadata_dir_for(root)?;
|
||||||
|
let (_,installed)=MetaDir::open(&meta)?;
|
||||||
|
assert_eq!(coa_core::fsx::canonicalize_lenient(std::path::Path::new(&installed.server_path))?,coa_core::fsx::canonicalize_lenient(root)?,"Fixture metadata must point to the copy");
|
||||||
|
assert!(installed.client_path.is_none(),"Detach the real client from fixture metadata");
|
||||||
|
let env=RepackEnv{root,meta_dir:&meta};
|
||||||
|
if args.get(3).is_some_and(|flag|flag=="--validate-only") {
|
||||||
|
let result=env.validate();
|
||||||
|
let _=coa_core::driver::run(root,coa_core::driver::Verb::StopAll);
|
||||||
|
result?;
|
||||||
|
println!("Startup and identity validation passed");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let outcome=update::apply(&update::Params{root,meta_dir:&meta,source:coa_core::pkgsource::Source::Dir(args[2].clone().into()),trusted_key:coa_core::signing::EMBEDDED_PUBLIC_KEY,cancel:Default::default(),resolutions:Default::default(),env:&env,fail_after_ops:None},&|phase,percent|println!("{percent}% {phase}"));
|
||||||
|
let stopped=coa_core::driver::run(root,coa_core::driver::Verb::StopAll);
|
||||||
|
println!("Shutdown after test: {:?}",stopped.map(|r|r.ok));
|
||||||
|
let outcome=outcome?;
|
||||||
|
println!("Final transaction: {:?}",outcome.txn.state);
|
||||||
|
env.ensure_stopped()?;
|
||||||
|
assert_eq!(outcome.txn.state,update::State::Committed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ pub struct RecoveryPoint {
|
|||||||
pub components: Vec<Component>,
|
pub components: Vec<Component>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub realm: crate::realms::Mode,
|
pub realm: crate::realms::Mode,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) mysql_snapshot: Option<crate::mysql_snapshot::Snapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCATION_FILE: &str = "backup-location.json";
|
const LOCATION_FILE: &str = "backup-location.json";
|
||||||
@@ -259,7 +261,7 @@ pub fn config_files(root: &Path) -> Vec<String> {
|
|||||||
for e in rd.flatten() {
|
for e in rd.flatten() {
|
||||||
let n = e.file_name().to_string_lossy().to_string();
|
let n = e.file_name().to_string_lossy().to_string();
|
||||||
// database.json holds generated database passwords; it is launcher state, not configuration.
|
// database.json holds generated database passwords; it is launcher state, not configuration.
|
||||||
if e.path().is_file() && (n.contains(".template") || n == "repack.json") {
|
if e.path().is_file() && (n.contains(".template") || n == "repack.json" || n == "docker.json") {
|
||||||
out.push(format!("Settings/{n}"));
|
out.push(format!("Settings/{n}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,6 +437,16 @@ pub fn create(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let mysql_snapshot = if kind == Kind::Full && matches!(trigger, Trigger::BeforeUpdate | Trigger::BeforeRepair) && cfg!(windows) && !crate::docker::is_docker(root) {
|
||||||
|
progress("Saving a complete stopped MySQL recovery copy");
|
||||||
|
match crate::mysql_snapshot::capture(root, &partial) {
|
||||||
|
Ok(snapshot) => Some(snapshot),
|
||||||
|
Err(error) => {
|
||||||
|
if let Ok(owned) = fsx::ensure_within(&backups_dir(meta), &partial) { let _ = fs::remove_dir_all(owned); }
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { None };
|
||||||
let point = RecoveryPoint {
|
let point = RecoveryPoint {
|
||||||
realm: crate::realms::state(root)?.active,
|
realm: crate::realms::state(root)?.active,
|
||||||
schema: 1,
|
schema: 1,
|
||||||
@@ -445,6 +457,7 @@ pub fn create(
|
|||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
manager_version: crate::MANAGER_VERSION.into(),
|
manager_version: crate::MANAGER_VERSION.into(),
|
||||||
components,
|
components,
|
||||||
|
mysql_snapshot,
|
||||||
};
|
};
|
||||||
fsx::atomic_write_json(&partial.join("backup.json"), &point)?;
|
fsx::atomic_write_json(&partial.join("backup.json"), &point)?;
|
||||||
fs::rename(&partial, &final_dir)?;
|
fs::rename(&partial, &final_dir)?;
|
||||||
@@ -481,6 +494,9 @@ 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();
|
||||||
|
if let Some(snapshot) = &point.mysql_snapshot {
|
||||||
|
if let Err(error) = crate::mysql_snapshot::verify(&dir, snapshot) { problems.push(error.to_string()); }
|
||||||
|
}
|
||||||
let mut names = std::collections::BTreeSet::new();
|
let mut names = std::collections::BTreeSet::new();
|
||||||
let mut schemas = std::collections::BTreeSet::new();
|
let mut schemas = std::collections::BTreeSet::new();
|
||||||
for c in &point.components {
|
for c in &point.components {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Signed channel pointers select one immutable package for an entire transaction.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
use crate::pkgsource::{fetch_small, Source};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct Pointer {
|
||||||
|
pub schema: u32,
|
||||||
|
pub channel: String,
|
||||||
|
pub version: String,
|
||||||
|
pub release_tag: String,
|
||||||
|
pub snapshot: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Envelope {
|
||||||
|
pub payload: String,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(bytes: &[u8], channel: &str, key: &str) -> Result<Pointer> {
|
||||||
|
let envelope: Envelope = serde_json::from_slice(bytes).map_err(|e| Error::InvalidManifest(e.to_string()))?;
|
||||||
|
crate::signing::verify(envelope.payload.as_bytes(), &envelope.signature, key)?;
|
||||||
|
let pointer: Pointer = serde_json::from_str(&envelope.payload).map_err(|e| Error::InvalidManifest(e.to_string()))?;
|
||||||
|
if pointer.schema != 1 || pointer.channel != channel
|
||||||
|
|| pointer.release_tag != format!("server-{}", pointer.version)
|
||||||
|
|| pointer.version.is_empty()
|
||||||
|
|| !pointer.version.bytes().all(|b| b.is_ascii_digit() || b == b'.')
|
||||||
|
|| pointer.snapshot.len() != 64
|
||||||
|
|| !pointer.snapshot.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return Err(Error::InvalidManifest("Invalid channel pointer".into()));
|
||||||
|
}
|
||||||
|
Ok(pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve(channel: &str) -> Result<Source> {
|
||||||
|
if channel != "stable" && channel != "edge" {
|
||||||
|
return Err(Error::InvalidManifest("Unknown release channel".into()));
|
||||||
|
}
|
||||||
|
let source = Source::Url("https://raw.githubusercontent.com/Corfirean/coa-server-build/channels".into());
|
||||||
|
let bytes = fetch_small(&source, &format!("{channel}.json"))?;
|
||||||
|
let pointer = verify(&bytes, channel, crate::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||||
|
Ok(Source::Url(format!("https://github.com/Corfirean/coa-server-build/releases/download/{}", pointer.release_tag)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(pointer: &Pointer, seed: &str) -> Result<Vec<u8>> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
let seed: [u8; 32] = STANDARD.decode(seed.trim()).map_err(|_| Error::Invalid("Invalid signing key".into()))?
|
||||||
|
.try_into().map_err(|_| Error::Invalid("Invalid signing key length".into()))?;
|
||||||
|
let payload = serde_json::to_string(pointer).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||||
|
let signature = STANDARD.encode(SigningKey::from_bytes(&seed).sign(payload.as_bytes()).to_bytes());
|
||||||
|
let bytes = serde_json::to_vec(&Envelope { payload, signature }).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||||
|
verify(&bytes, &pointer.channel, crate::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
fn signed(pointer: &Pointer) -> (Vec<u8>, String) {
|
||||||
|
let sk = SigningKey::from_bytes(&[17; 32]);
|
||||||
|
let payload = serde_json::to_string(pointer).unwrap();
|
||||||
|
let signature = STANDARD.encode(sk.sign(payload.as_bytes()).to_bytes());
|
||||||
|
(serde_json::to_vec(&Envelope { payload, signature }).unwrap(), STANDARD.encode(sk.verifying_key().to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifies_identity_signature_and_immutable_tag() {
|
||||||
|
let mut p = Pointer { schema: 1, channel: "stable".into(), version: "0.261010.40".into(), release_tag: "server-0.261010.40".into(), snapshot: "a".repeat(64) };
|
||||||
|
let (bytes, key) = signed(&p);
|
||||||
|
assert_eq!(verify(&bytes, "stable", &key).unwrap().version, p.version);
|
||||||
|
assert!(verify(&bytes, "edge", &key).is_err());
|
||||||
|
let mut changed: Envelope = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
changed.payload = changed.payload.replace("40", "41");
|
||||||
|
assert!(verify(&serde_json::to_vec(&changed).unwrap(), "stable", &key).is_err());
|
||||||
|
p.release_tag = "stable".into();
|
||||||
|
let (bytes, key) = signed(&p);
|
||||||
|
assert!(verify(&bytes, "stable", &key).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use crate::backup;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::fsx;
|
use crate::fsx;
|
||||||
use crate::migrations::{apply_pending, status, Status};
|
use crate::migrations::{apply_pending, status, Status};
|
||||||
use crate::release::{collect_bots_sql, collect_core_sql};
|
use crate::release::{collect_bots_sql, collect_core_install_sql};
|
||||||
|
|
||||||
pub struct Params<'a> {
|
pub struct Params<'a> {
|
||||||
pub repack: &'a Path,
|
pub repack: &'a Path,
|
||||||
@@ -24,7 +24,7 @@ pub struct Params<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Auth tables that hold player data. Everything else (permissions, build info, realmlist) is configuration.
|
/// Auth tables that hold player data. Everything else (permissions, build info, realmlist) is configuration.
|
||||||
const AUTH_PLAYER_TABLES: [&str; 11] = [
|
pub(crate) const AUTH_PLAYER_TABLES: [&str; 11] = [
|
||||||
"account",
|
"account",
|
||||||
"account_access",
|
"account_access",
|
||||||
"account_banned",
|
"account_banned",
|
||||||
@@ -237,7 +237,7 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> {
|
|||||||
}
|
}
|
||||||
fsx::atomic_write_json(&repack_json, &cfg)?;
|
fsx::atomic_write_json(&repack_json, &cfg)?;
|
||||||
|
|
||||||
let mut sql = collect_core_sql(p.core)?;
|
let mut sql = collect_core_install_sql(p.core)?;
|
||||||
if let Some(b) = p.bots {
|
if let Some(b) = p.bots {
|
||||||
sql.extend(collect_bots_sql(b)?);
|
sql.extend(collect_bots_sql(b)?);
|
||||||
}
|
}
|
||||||
@@ -418,6 +418,7 @@ mod tests {
|
|||||||
"Settings/worldserver.conf.template",
|
"Settings/worldserver.conf.template",
|
||||||
"mysql/bin/mysqld.exe",
|
"mysql/bin/mysqld.exe",
|
||||||
"mysql/share/english/errmsg.sys",
|
"mysql/share/english/errmsg.sys",
|
||||||
|
"mysql/lib/private/icudt.dat",
|
||||||
"BugReport/relay.py",
|
"BugReport/relay.py",
|
||||||
"Core/libssl-3-x64.dll",
|
"Core/libssl-3-x64.dll",
|
||||||
"README.txt",
|
"README.txt",
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ pub fn drift(root: &Path) -> Result<Vec<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(root: &Path, scope: Scope) -> Result<SettingsView> {
|
pub fn load(root: &Path, scope: Scope) -> Result<SettingsView> {
|
||||||
|
// A Docker server that was added but never started has no active world configuration yet; create it from its template.
|
||||||
|
if scope == Scope::Server && crate::docker::is_docker(root) {
|
||||||
|
let _ = crate::docker::ensure_main_configs(root);
|
||||||
|
}
|
||||||
let schema = scope.schema();
|
let schema = scope.schema();
|
||||||
let t = targets(root, scope)?;
|
let t = targets(root, scope)?;
|
||||||
let conf = load_conf(&t.read)?;
|
let conf = load_conf(&t.read)?;
|
||||||
@@ -259,9 +263,12 @@ pub fn load(root: &Path, scope: Scope) -> Result<SettingsView> {
|
|||||||
let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect();
|
let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect();
|
||||||
let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count();
|
let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count();
|
||||||
|
|
||||||
|
// On a Docker server some settings are fixed by the backend (environment beats the file): not offered.
|
||||||
|
let docker = scope == Scope::Server && crate::docker::is_docker(root);
|
||||||
let settings = schema
|
let settings = schema
|
||||||
.settings
|
.settings
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|s| !(docker && crate::docker::managed_setting(&s.key)))
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let raw = conf.get(&s.key);
|
let raw = conf.get(&s.key);
|
||||||
let (value, problem) = match raw {
|
let (value, problem) = match raw {
|
||||||
@@ -357,14 +364,30 @@ pub fn validate(
|
|||||||
let schema = scope.schema();
|
let schema = scope.schema();
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
let mut raws = BTreeMap::new();
|
let mut raws = BTreeMap::new();
|
||||||
|
let docker = scope == Scope::Server && crate::docker::is_docker(root);
|
||||||
for (key, value) in changes {
|
for (key, value) in changes {
|
||||||
match schema.get(key) {
|
match schema.get(key) {
|
||||||
None => errors.push(FieldError {
|
None => errors.push(FieldError {
|
||||||
key: key.clone(),
|
key: key.clone(),
|
||||||
message: "is not a setting the Manager can change".into(),
|
message: "is not a setting the Manager can change".into(),
|
||||||
}),
|
}),
|
||||||
|
Some(_) if docker && crate::docker::managed_setting(key) => errors.push(FieldError {
|
||||||
|
key: key.clone(),
|
||||||
|
message:
|
||||||
|
"is set by the Docker backend; changing it in the file would have no effect"
|
||||||
|
.into(),
|
||||||
|
}),
|
||||||
Some(s) => match s.to_raw(value) {
|
Some(s) => match s.to_raw(value) {
|
||||||
Ok(raw) => {
|
Ok(raw) => {
|
||||||
|
if let Some(problem) = crate::docker::setting_problem(key, &raw)
|
||||||
|
.filter(|_| crate::docker::is_docker(root))
|
||||||
|
{
|
||||||
|
errors.push(FieldError {
|
||||||
|
key: key.clone(),
|
||||||
|
message: problem.into(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
raws.insert(key.clone(), raw);
|
raws.insert(key.clone(), raw);
|
||||||
}
|
}
|
||||||
Err(m) => errors.push(FieldError {
|
Err(m) => errors.push(FieldError {
|
||||||
@@ -1077,6 +1100,61 @@ Spellbook.New = 5
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_docker_server_is_not_offered_the_settings_its_backend_fixes() {
|
||||||
|
let (_d, root, meta) = fixture();
|
||||||
|
let keys = |root: &Path| {
|
||||||
|
load(root, Scope::Server)
|
||||||
|
.unwrap()
|
||||||
|
.settings
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.meta.key)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for k in [
|
||||||
|
"Ra.Enable",
|
||||||
|
"Ra.IP",
|
||||||
|
"Updates.EnableDatabases",
|
||||||
|
"PlayerLimit",
|
||||||
|
] {
|
||||||
|
assert!(keys(&root).iter().any(|x| x == k), "a repack offers {k}");
|
||||||
|
}
|
||||||
|
fs::write(root.join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap();
|
||||||
|
let docker = keys(&root);
|
||||||
|
for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases"] {
|
||||||
|
assert!(
|
||||||
|
!docker.iter().any(|x| x == k),
|
||||||
|
"a Docker server does not offer {k}: the containers' environment beats the file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
docker.iter().any(|x| x == "PlayerLimit"),
|
||||||
|
"everything else is still offered"
|
||||||
|
);
|
||||||
|
// Forcing a change through is refused and says why, instead of being saved and ignored.
|
||||||
|
let err = save(
|
||||||
|
&root,
|
||||||
|
&meta,
|
||||||
|
Scope::Server,
|
||||||
|
&set(&[("Ra.Enable", json!(0))]),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("Docker backend"), "{err}");
|
||||||
|
assert!(
|
||||||
|
!fs::read_to_string(root.join("Core/configs/worldserver.conf"))
|
||||||
|
.unwrap()
|
||||||
|
.contains("Ra.Enable = 0")
|
||||||
|
);
|
||||||
|
save(
|
||||||
|
&root,
|
||||||
|
&meta,
|
||||||
|
Scope::Server,
|
||||||
|
&set(&[("PlayerLimit", json!(77))]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_key_is_appended_and_noop_saves_do_nothing() {
|
fn missing_key_is_appended_and_noop_saves_do_nothing() {
|
||||||
let (_d, root, meta) = fixture();
|
let (_d, root, meta) = fixture();
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ fn classify(l: &str) -> Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last `max_lines` lines of some text (after filtering), classified and redacted like the lines of a log file.
|
||||||
|
pub fn lines_from_text(text: &str, filter: Option<&str>, max_lines: usize) -> Vec<Line> {
|
||||||
|
let needle = filter.map(str::to_lowercase).filter(|n| !n.is_empty());
|
||||||
|
let out: Vec<Line> = text
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.filter(|l| needle.as_ref().map(|n| l.to_lowercase().contains(n)).unwrap_or(true))
|
||||||
|
.map(|l| Line { level: classify(l), text: crate::diag::redact(l) })
|
||||||
|
.collect();
|
||||||
|
let skip = out.len().saturating_sub(max_lines);
|
||||||
|
out.into_iter().skip(skip).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The last `max_lines` lines (after filtering) from the last ~512 KB of a log. Works on multi-gigabyte files.
|
/// The last `max_lines` lines (after filtering) from the last ~512 KB of a log. Works on multi-gigabyte files.
|
||||||
pub fn tail(path: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
|
pub fn tail(path: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
|
||||||
const WINDOW: u64 = 512 * 1024;
|
const WINDOW: u64 = 512 * 1024;
|
||||||
|
|||||||
+833
-163
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547",
|
||||||
|
"EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50",
|
||||||
|
"EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8",
|
||||||
|
"EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d",
|
||||||
|
"EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba",
|
||||||
|
"EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f",
|
||||||
|
"EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02",
|
||||||
|
"EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2",
|
||||||
|
"EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||||
|
"EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f",
|
||||||
|
"EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229",
|
||||||
|
"EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938",
|
||||||
|
"EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3",
|
||||||
|
"EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3",
|
||||||
|
"EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa",
|
||||||
|
"EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||||
|
"EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||||
|
"EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606",
|
||||||
|
"EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a",
|
||||||
|
"EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb",
|
||||||
|
"EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e",
|
||||||
|
"EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387",
|
||||||
|
"EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71",
|
||||||
|
"EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c",
|
||||||
|
"EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296",
|
||||||
|
"EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4",
|
||||||
|
"EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e",
|
||||||
|
"dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c",
|
||||||
|
"Data/patch-T.MPQ": "fe597138ab7f56881b5f4f27d6dc9d81bb278b483ca0d91b7207ffd4a5ae7bfd",
|
||||||
|
"Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf",
|
||||||
|
"Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484",
|
||||||
|
"Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248",
|
||||||
|
"Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934",
|
||||||
|
"Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a",
|
||||||
|
"Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f",
|
||||||
|
"Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39",
|
||||||
|
"Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c",
|
||||||
|
"Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0",
|
||||||
|
"Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de",
|
||||||
|
"Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61",
|
||||||
|
"Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020",
|
||||||
|
"Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283",
|
||||||
|
"Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c",
|
||||||
|
"Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87",
|
||||||
|
"Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d",
|
||||||
|
"Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617",
|
||||||
|
"Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb",
|
||||||
|
"Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3",
|
||||||
|
"Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8",
|
||||||
|
"Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c",
|
||||||
|
"Data/patch-ZZU.MPQ": "8ee1839415b56d46c6eff7eb4dfbaf6fbafc4c6b29b39380d69f4d827d02a99e",
|
||||||
|
"Data/patch-ZZV.MPQ": "8d5e61d37008abf70478e5d5f46ec7afb0490b9a643323bd75a1c70cadd63290",
|
||||||
|
"Data/patch-ZZW.MPQ": "ed452a0a0118c9f4deaf02db753064ac95b6e4a84022bdabd39a7e70d775223f"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283",
|
||||||
|
"Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c",
|
||||||
|
"Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87",
|
||||||
|
"Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d",
|
||||||
|
"Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617",
|
||||||
|
"Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb",
|
||||||
|
"Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3",
|
||||||
|
"Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547",
|
||||||
|
"EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50",
|
||||||
|
"EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8",
|
||||||
|
"EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d",
|
||||||
|
"EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba",
|
||||||
|
"EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f",
|
||||||
|
"EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02",
|
||||||
|
"EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2",
|
||||||
|
"EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||||
|
"EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f",
|
||||||
|
"EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229",
|
||||||
|
"EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938",
|
||||||
|
"EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3",
|
||||||
|
"EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3",
|
||||||
|
"EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa",
|
||||||
|
"EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||||
|
"EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||||
|
"EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606",
|
||||||
|
"EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a",
|
||||||
|
"EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb",
|
||||||
|
"EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e",
|
||||||
|
"EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387",
|
||||||
|
"EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71",
|
||||||
|
"EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c",
|
||||||
|
"EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296",
|
||||||
|
"EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4",
|
||||||
|
"EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e",
|
||||||
|
"dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c",
|
||||||
|
"Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5",
|
||||||
|
"Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf",
|
||||||
|
"Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484",
|
||||||
|
"Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248",
|
||||||
|
"Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934",
|
||||||
|
"Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a",
|
||||||
|
"Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f",
|
||||||
|
"Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39",
|
||||||
|
"Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c",
|
||||||
|
"Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0",
|
||||||
|
"Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de",
|
||||||
|
"Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61",
|
||||||
|
"Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547",
|
||||||
|
"EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50",
|
||||||
|
"EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8",
|
||||||
|
"EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d",
|
||||||
|
"EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba",
|
||||||
|
"EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f",
|
||||||
|
"EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02",
|
||||||
|
"EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2",
|
||||||
|
"EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||||
|
"EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f",
|
||||||
|
"EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229",
|
||||||
|
"EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938",
|
||||||
|
"EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3",
|
||||||
|
"EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3",
|
||||||
|
"EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa",
|
||||||
|
"EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||||
|
"EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||||
|
"EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606",
|
||||||
|
"EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a",
|
||||||
|
"EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb",
|
||||||
|
"EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e",
|
||||||
|
"EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387",
|
||||||
|
"EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71",
|
||||||
|
"EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c",
|
||||||
|
"EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296",
|
||||||
|
"EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4",
|
||||||
|
"EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e",
|
||||||
|
"dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c",
|
||||||
|
"Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5",
|
||||||
|
"Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf",
|
||||||
|
"Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484",
|
||||||
|
"Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248",
|
||||||
|
"Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934",
|
||||||
|
"Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a",
|
||||||
|
"Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f",
|
||||||
|
"Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39",
|
||||||
|
"Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c",
|
||||||
|
"Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0",
|
||||||
|
"Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de",
|
||||||
|
"Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61",
|
||||||
|
"Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020",
|
||||||
|
"Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283",
|
||||||
|
"Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c",
|
||||||
|
"Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87",
|
||||||
|
"Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d",
|
||||||
|
"Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617",
|
||||||
|
"Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb",
|
||||||
|
"Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3",
|
||||||
|
"Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8",
|
||||||
|
"Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c"
|
||||||
|
}
|
||||||
@@ -364,6 +364,13 @@ impl Db {
|
|||||||
.map_err(|_| Error::Invalid("unexpected reply".into()))
|
.map_err(|_| Error::Invalid("unexpected reply".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn recovery_objects(&self, schema: &str) -> Result<Vec<serde_json::Value>> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
|
if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); }
|
||||||
|
let sql = format!("SELECT routine_type,HEX(routine_name) FROM information_schema.routines WHERE routine_schema='{schema}' UNION ALL SELECT 'TRIGGER',HEX(trigger_name) FROM information_schema.triggers WHERE trigger_schema='{schema}' UNION ALL SELECT 'VIEW',HEX(table_name) FROM information_schema.views WHERE table_schema='{schema}' ORDER BY 1,2;");
|
||||||
|
parse_recovery_objects(&schema, &self.query(&sql)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
|
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
|
||||||
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
|
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
|
||||||
let schema = self.realm_schema(schema);
|
let schema = self.realm_schema(schema);
|
||||||
@@ -470,6 +477,18 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_recovery_objects(schema: &str, text: &str) -> Result<Vec<serde_json::Value>> {
|
||||||
|
text.lines().filter(|line| !line.is_empty()).map(|line| {
|
||||||
|
let (kind, name) = line.split_once('\t').ok_or_else(|| Error::Invalid("unexpected database object reply".into()))?;
|
||||||
|
if !matches!(kind, "PROCEDURE" | "FUNCTION" | "TRIGGER" | "VIEW") {
|
||||||
|
return Err(Error::Invalid("unexpected database object type".into()));
|
||||||
|
}
|
||||||
|
let name = hex::decode(name).ok().and_then(|bytes| String::from_utf8(bytes).ok())
|
||||||
|
.filter(|name| !name.is_empty()).ok_or_else(|| Error::Invalid("unexpected database object name".into()))?;
|
||||||
|
Ok(serde_json::json!({ "database": schema, "kind": kind, "name": name }))
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the service account the Manager uses for the server console (RA). Accounts are stored upper-case.
|
/// Name of the service account the Manager uses for the server console (RA). Accounts are stored upper-case.
|
||||||
pub const SERVICE_ACCOUNT: &str = "COAMANAGER";
|
pub const SERVICE_ACCOUNT: &str = "COAMANAGER";
|
||||||
|
|
||||||
@@ -578,6 +597,19 @@ mod tests {
|
|||||||
const ROOT_PW: &str = "rootpw-ZZ1";
|
const ROOT_PW: &str = "rootpw-ZZ1";
|
||||||
const APP_PW: &str = "apppw-QQ2";
|
const APP_PW: &str = "apppw-QQ2";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_object_names_preserve_unicode_and_delimiters() {
|
||||||
|
let name = "view\tперсонажи\n";
|
||||||
|
let text = format!("VIEW\t{}\nTRIGGER\t74726967676572\n", hex::encode(name));
|
||||||
|
let objects = parse_recovery_objects("acore_characters", &text).unwrap();
|
||||||
|
assert_eq!(objects.len(), 2);
|
||||||
|
assert_eq!(objects[0]["name"], name);
|
||||||
|
assert_eq!(objects[1]["kind"], "TRIGGER");
|
||||||
|
assert!(parse_recovery_objects("test", "VIEW\tzz").is_err());
|
||||||
|
assert!(parse_recovery_objects("test", "UNKNOWN\t61").is_err());
|
||||||
|
assert!(parse_recovery_objects("test", "VIEW").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
fn folder(docker: bool) -> (tempfile::TempDir, PathBuf) {
|
fn folder(docker: bool) -> (tempfile::TempDir, PathBuf) {
|
||||||
let d = tempfile::tempdir().unwrap();
|
let d = tempfile::tempdir().unwrap();
|
||||||
let root = d.path().join("srv");
|
let root = d.path().join("srv");
|
||||||
|
|||||||
+124
-1
@@ -199,7 +199,12 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let exposed: Vec<&str> = crate::net::exposure(&ports)
|
let exposure = if crate::docker::is_docker(root) {
|
||||||
|
crate::docker::exposure(root, &ports)
|
||||||
|
} else {
|
||||||
|
crate::net::exposure(&ports)
|
||||||
|
};
|
||||||
|
let exposed: Vec<&str> = exposure
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
(e.what == "database" || e.what == "server console") && e.reachable_from_network
|
(e.what == "database" || e.what == "server console") && e.reachable_from_network
|
||||||
@@ -690,6 +695,18 @@ pub fn export_package(
|
|||||||
"summary.json",
|
"summary.json",
|
||||||
serde_json::to_string_pretty(&summary)?.as_bytes(),
|
serde_json::to_string_pretty(&summary)?.as_bytes(),
|
||||||
)?;
|
)?;
|
||||||
|
let owner = fs::read_to_string(meta_dir.join("update-lock-owner.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| serde_json::from_str::<serde_json::Value>(&redact_json(&text)).ok());
|
||||||
|
let maintenance = serde_json::json!({
|
||||||
|
"lockFilePresent": meta_dir.join("update.lock").is_file(),
|
||||||
|
"lastRecordedOwner": owner,
|
||||||
|
"note": "The lock file and owner record may remain after completion. Their presence does not establish whether the OS lock is currently held."
|
||||||
|
});
|
||||||
|
add(
|
||||||
|
"maintenance.json",
|
||||||
|
serde_json::to_string_pretty(&maintenance)?.as_bytes(),
|
||||||
|
)?;
|
||||||
|
|
||||||
// Logs. The newest update logs of the repack's own updater are added under their own names.
|
// Logs. The newest update logs of the repack's own updater are added under their own names.
|
||||||
let mut logs: Vec<(String, PathBuf)> = vec![
|
let mut logs: Vec<(String, PathBuf)> = vec![
|
||||||
@@ -796,6 +813,29 @@ pub fn export_package(
|
|||||||
add("processes.txt", processes_report(root, &ports).as_bytes())?;
|
add("processes.txt", processes_report(root, &ports).as_bytes())?;
|
||||||
|
|
||||||
// The newest update journals: the state of each update and the reason it failed.
|
// The newest update journals: the state of each update and the reason it failed.
|
||||||
|
for (path, size, _) in newest_files(&meta_dir.join("diagnostics"), "json", usize::MAX)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(path, _, _)| {
|
||||||
|
path.file_name().is_some_and(|name| {
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
name.starts_with("schema-validation-")
|
||||||
|
|| name.starts_with("database-recovery-")
|
||||||
|
|| name.starts_with("database-rehearsal-")
|
||||||
|
|| name.starts_with("database-restore-")
|
||||||
|
|| name.starts_with("database-repair-")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(10)
|
||||||
|
{
|
||||||
|
if size <= 4 * 1024 * 1024 {
|
||||||
|
if let Ok(text) = fs::read_to_string(&path) {
|
||||||
|
add(
|
||||||
|
&format!("schema/{}", path.file_name().unwrap().to_string_lossy()),
|
||||||
|
redact_json(&text).as_bytes(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut journals: Vec<_> = fs::read_dir(meta_dir.join("updates"))
|
let mut journals: Vec<_> = fs::read_dir(meta_dir.join("updates"))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -882,6 +922,7 @@ pub fn stamp() -> String {
|
|||||||
chrono::Local::now().format("%Y%m%d-%H%M%S").to_string()
|
chrono::Local::now().format("%Y%m%d-%H%M%S").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
pub fn desktop_or_temp() -> PathBuf {
|
pub fn desktop_or_temp() -> PathBuf {
|
||||||
let d = std::env::var_os("USERPROFILE")
|
let d = std::env::var_os("USERPROFILE")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -894,11 +935,80 @@ pub fn desktop_or_temp() -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a file meant for the person goes on Linux: the desktop folder they have (the XDG user directories file names it), else
|
||||||
|
/// `~/Desktop`, else `~/Downloads`, else the temporary folder.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn desktop_or_temp() -> PathBuf {
|
||||||
|
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||||
|
return std::env::temp_dir();
|
||||||
|
};
|
||||||
|
let dirs = std::fs::read_to_string(home.join(".config/user-dirs.dirs")).ok();
|
||||||
|
pick_desktop(&home, dirs.as_deref(), &|p| p.is_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn pick_desktop(home: &Path, user_dirs: Option<&str>, exists: &dyn Fn(&Path) -> bool) -> PathBuf {
|
||||||
|
let named = user_dirs.and_then(|t| {
|
||||||
|
t.lines().find_map(|l| {
|
||||||
|
let v = l.trim().strip_prefix("XDG_DESKTOP_DIR=")?.trim_matches('"');
|
||||||
|
Some(PathBuf::from(v.replace("$HOME", &home.to_string_lossy())))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// A user without a desktop sets XDG_DESKTOP_DIR to the home folder itself: that is not a place to drop files into.
|
||||||
|
[
|
||||||
|
named,
|
||||||
|
Some(home.join("Desktop")),
|
||||||
|
Some(home.join("Downloads")),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|p| p != home)
|
||||||
|
.find(|p| exists(p))
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::registry::{InstallKind, InstallMeta};
|
use crate::registry::{InstallKind, InstallMeta};
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn the_desktop_folder_on_linux_follows_the_users_own_directories() {
|
||||||
|
let home = Path::new("/home/ana");
|
||||||
|
let only =
|
||||||
|
|wanted: &'static [&'static str]| move |p: &Path| wanted.contains(&p.to_str().unwrap());
|
||||||
|
let dirs =
|
||||||
|
"# comment\nXDG_DOWNLOAD_DIR=\"$HOME/Downloads\"\nXDG_DESKTOP_DIR=\"$HOME/Bureau\"\n";
|
||||||
|
assert_eq!(
|
||||||
|
pick_desktop(
|
||||||
|
home,
|
||||||
|
Some(dirs),
|
||||||
|
&only(&["/home/ana/Bureau", "/home/ana/Desktop"])
|
||||||
|
),
|
||||||
|
PathBuf::from("/home/ana/Bureau")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_desktop(home, None, &only(&["/home/ana/Desktop"])),
|
||||||
|
PathBuf::from("/home/ana/Desktop")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_desktop(home, Some(dirs), &only(&["/home/ana/Downloads"])),
|
||||||
|
PathBuf::from("/home/ana/Downloads")
|
||||||
|
);
|
||||||
|
assert_eq!(pick_desktop(home, None, &only(&[])), std::env::temp_dir());
|
||||||
|
// No desktop: the user directories file names the home folder itself.
|
||||||
|
let none = "XDG_DESKTOP_DIR=\"$HOME/\"\n";
|
||||||
|
assert_eq!(
|
||||||
|
pick_desktop(
|
||||||
|
home,
|
||||||
|
Some(none),
|
||||||
|
&only(&["/home/ana", "/home/ana/Downloads"])
|
||||||
|
),
|
||||||
|
PathBuf::from("/home/ana/Downloads")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repeated_config_warnings_are_folded() {
|
fn repeated_config_warnings_are_folded() {
|
||||||
let t = "start
|
let t = "start
|
||||||
@@ -1115,6 +1225,11 @@ real error
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let meta_dir = d.path().join("srv.manager");
|
let meta_dir = d.path().join("srv.manager");
|
||||||
fs::create_dir_all(&meta_dir).unwrap();
|
fs::create_dir_all(&meta_dir).unwrap();
|
||||||
|
fs::write(meta_dir.join("update.lock"), b"").unwrap();
|
||||||
|
fs::write(meta_dir.join("update-lock-owner.json"), r#"{"operation":"checking updates","pid":1234,"started":"2026-10-13T01:00:00Z","token":"owner-secret"}"#).unwrap();
|
||||||
|
fs::create_dir_all(meta_dir.join("diagnostics")).unwrap();
|
||||||
|
fs::write(meta_dir.join("diagnostics/schema-validation-CoA-test.json"), r#"{"problems":[{"table":"creature_display_preset","column":"display_id","detail":"default mismatch"}],"password":"schema-secret"}"#).unwrap();
|
||||||
|
fs::write(meta_dir.join("diagnostics/database-recovery-test.json"), r#"{"objects":[{"database":"acore_characters","kind":"TRIGGER","name":"user_character_trigger"}],"password":"recovery-secret"}"#).unwrap();
|
||||||
let meta = InstallMeta::new(InstallKind::Imported, &root);
|
let meta = InstallMeta::new(InstallKind::Imported, &root);
|
||||||
let report = run(&root, &meta);
|
let report = run(&root, &meta);
|
||||||
let zip_path = d.path().join("diag.zip");
|
let zip_path = d.path().join("diag.zip");
|
||||||
@@ -1140,6 +1255,14 @@ real error
|
|||||||
"keys and ordinary log lines are included"
|
"keys and ordinary log lines are included"
|
||||||
);
|
);
|
||||||
assert!(all.contains("v1.8") && all.contains("48c4786a"));
|
assert!(all.contains("v1.8") && all.contains("48c4786a"));
|
||||||
|
assert!(all.contains("creature_display_preset") && all.contains("default mismatch"));
|
||||||
|
assert!(!all.contains("schema-secret"));
|
||||||
|
assert!(all.contains("user_character_trigger") && all.contains("acore_characters"));
|
||||||
|
assert!(!all.contains("recovery-secret"));
|
||||||
|
assert!(all.contains("checking updates") && all.contains("1234"));
|
||||||
|
assert!(all.contains("lockFilePresent") && all.contains("does not establish"));
|
||||||
|
assert!(!all.contains("owner-secret"));
|
||||||
|
assert_eq!(fs::read(meta_dir.join("update.lock")).unwrap(), b"");
|
||||||
assert!(
|
assert!(
|
||||||
!all.contains("hunter2")
|
!all.contains("hunter2")
|
||||||
&& !all.contains("SECRETPW")
|
&& !all.contains("SECRETPW")
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ pub fn preflight(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_problems(data_dir: &Path) -> Vec<crate::install::Problem> {
|
pub(crate) fn data_problems(data_dir: &Path) -> Vec<crate::install::Problem> {
|
||||||
let s = data_dir.to_string_lossy();
|
let s = data_dir.to_string_lossy();
|
||||||
if !data_dir.is_absolute() || s.contains(':') {
|
if !data_dir.is_absolute() || s.contains(':') {
|
||||||
return vec![problem(
|
return vec![problem(
|
||||||
|
|||||||
@@ -297,6 +297,10 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
|
|||||||
run_container(d, &call, &n.world, log)?;
|
run_container(d, &call, &n.world, log)?;
|
||||||
}
|
}
|
||||||
wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?;
|
wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?;
|
||||||
|
// The core leaves with code 2 for `server restart` and expects a supervisor to start it again (the repack has one). Docker
|
||||||
|
// does that for any non-zero exit, but only once the server has come up: a start that fails must stay failed, not loop.
|
||||||
|
// A stop by `docker stop` or `server shutdown` (code 0) is never restarted.
|
||||||
|
let _ = docker(d, &["update", "--restart", "on-failure:3", &n.world], QUICK);
|
||||||
|
|
||||||
if !state.get(&n.auth).is_some_and(Container::running) {
|
if !state.get(&n.auth).is_some_and(Container::running) {
|
||||||
remove(d, &n.auth);
|
remove(d, &n.auth);
|
||||||
@@ -315,9 +319,51 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
|
|||||||
run_container(d, &call, &n.auth, log)?;
|
run_container(d, &call, &n.auth, log)?;
|
||||||
}
|
}
|
||||||
wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?;
|
wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?;
|
||||||
|
mark_realm_online(d, &n, &secrets, realm, log);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The auth server marks every realm offline when it starts, and the world server (already up, see the order above) only
|
||||||
|
/// puts itself back after its next uptime update, ten minutes later by default: until then the client shows "Realm is
|
||||||
|
/// Offline". The world is known to be listening here, so say so for the realm it serves. The other realm of a Wildcard
|
||||||
|
/// install keeps the offline flag it is given on purpose (`realms::setup_realmlist`).
|
||||||
|
fn mark_realm_online(
|
||||||
|
d: &dyn Docker,
|
||||||
|
n: &Names,
|
||||||
|
secrets: &Secrets,
|
||||||
|
realm: crate::realms::Mode,
|
||||||
|
log: &mut Log,
|
||||||
|
) {
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE acore_auth.realmlist SET flag = flag & ~2 WHERE id = {};",
|
||||||
|
realm.realm_id()
|
||||||
|
);
|
||||||
|
let mut call = Call::new(
|
||||||
|
&[
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
"-e",
|
||||||
|
"MYSQL_PWD",
|
||||||
|
&n.db,
|
||||||
|
"mysql",
|
||||||
|
"--user=root",
|
||||||
|
"--protocol=tcp",
|
||||||
|
"--host=127.0.0.1",
|
||||||
|
],
|
||||||
|
QUICK,
|
||||||
|
);
|
||||||
|
call.env = vec![("MYSQL_PWD".into(), secrets.root.clone())];
|
||||||
|
call.stdin = Some(sql.as_bytes());
|
||||||
|
match d.run(&call) {
|
||||||
|
Ok(o) if o.ok() => {}
|
||||||
|
Ok(o) => log.say(format!(
|
||||||
|
"the realm could not be marked online: {}",
|
||||||
|
o.text().trim()
|
||||||
|
)),
|
||||||
|
Err(e) => log.say(format!("the realm could not be marked online: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn remove(d: &dyn Docker, name: &str) {
|
fn remove(d: &dyn Docker, name: &str) {
|
||||||
// A stopped container of the same name is replaced, so its settings always match the files on disk.
|
// A stopped container of the same name is replaced, so its settings always match the files on disk.
|
||||||
let _ = d.run(&Call::new(&["rm", "-f", name], QUICK));
|
let _ = d.run(&Call::new(&["rm", "-f", name], QUICK));
|
||||||
@@ -508,6 +554,30 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St
|
|||||||
env
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn forced_variables() -> Vec<String> {
|
||||||
|
let cfg = Config {
|
||||||
|
project: "t".into(),
|
||||||
|
bind_address: "127.0.0.1".into(),
|
||||||
|
mysql_image: "mysql:8.4".into(),
|
||||||
|
data_dir: None,
|
||||||
|
mysql_data: None,
|
||||||
|
};
|
||||||
|
let n = cfg.names();
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for kind in [GameKind::World, GameKind::Auth] {
|
||||||
|
let args = game_args(&cfg, &n, kind, Path::new("/s"), Path::new("/d"), 1, 2, None);
|
||||||
|
found.extend(args.iter().filter_map(|a| {
|
||||||
|
a.strip_prefix("AC_")
|
||||||
|
.map(|rest| format!("AC_{}", rest.split('=').next().unwrap()))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
found.sort();
|
||||||
|
found.dedup();
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
/// Realm profiles are only in play once a profile has been chosen on this server (`Settings/realm-profile.json`).
|
/// Realm profiles are only in play once a profile has been chosen on this server (`Settings/realm-profile.json`).
|
||||||
fn prepare_realm(root: &Path, log: &mut Log) -> Step<crate::realms::Mode> {
|
fn prepare_realm(root: &Path, log: &mut Log) -> Step<crate::realms::Mode> {
|
||||||
let fail_with = |e: crate::error::Error| Failure {
|
let fail_with = |e: crate::error::Error| Failure {
|
||||||
@@ -948,6 +1018,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
out(0, "containerid\n", "")
|
out(0, "containerid\n", "")
|
||||||
}
|
}
|
||||||
|
["update", "--restart", _, _] => out(0, "", ""),
|
||||||
|
["exec", ..] => out(0, "", ""),
|
||||||
["stop", "--time", _, name] => {
|
["stop", "--time", _, name] => {
|
||||||
self.running.borrow_mut().remove(*name);
|
self.running.borrow_mut().remove(*name);
|
||||||
self.exited
|
self.exited
|
||||||
@@ -1016,6 +1088,17 @@ mod tests {
|
|||||||
["coa-t1-db", "coa-t1-world", "coa-t1-auth"],
|
["coa-t1-db", "coa-t1-world", "coa-t1-auth"],
|
||||||
"database, then world, then auth"
|
"database, then world, then auth"
|
||||||
);
|
);
|
||||||
|
let exec = sim.calls_of("exec");
|
||||||
|
assert_eq!(
|
||||||
|
exec.len(),
|
||||||
|
1,
|
||||||
|
"the realm is marked online once the auth server is up"
|
||||||
|
);
|
||||||
|
assert!(exec[0].stdin && exec[0].env == [("MYSQL_PWD".to_string(), ROOT_PW.to_string())]);
|
||||||
|
assert!(
|
||||||
|
!exec[0].args.iter().any(|a| a.contains(ROOT_PW)),
|
||||||
|
"the password is not on the command line"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
at("build") < verbs.iter().rposition(|v| v.starts_with("run")).unwrap(),
|
at("build") < verbs.iter().rposition(|v| v.starts_with("run")).unwrap(),
|
||||||
"the runtime image exists before the game servers"
|
"the runtime image exists before the game servers"
|
||||||
@@ -1119,6 +1202,34 @@ mod tests {
|
|||||||
.any(|a| a == "--lower-case-table-names=1" || a == "--user"));
|
.any(|a| a == "--lower-case-table-names=1" || a == "--user"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_world_server_is_started_again_when_it_restarts_itself_but_only_once_it_is_up() {
|
||||||
|
let (_d, root) = server("t1");
|
||||||
|
let sim = Sim::new();
|
||||||
|
assert!(start_all(&sim, &root).ok);
|
||||||
|
let world_run = sim
|
||||||
|
.calls_of("run")
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.args.iter().any(|a| a == "coa-t1-world"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!world_run.args.iter().any(|a| a == "--restart"),
|
||||||
|
"no restart while it is still starting"
|
||||||
|
);
|
||||||
|
let updates = sim.calls_of("update");
|
||||||
|
assert_eq!(updates.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
updates[0].args,
|
||||||
|
["update", "--restart", "on-failure:3", "coa-t1-world"]
|
||||||
|
);
|
||||||
|
let verbs = sim.verbs();
|
||||||
|
let pos = |p: &str| verbs.iter().position(|v| v.starts_with(p)).unwrap();
|
||||||
|
assert!(
|
||||||
|
pos("run --detach") < pos("update"),
|
||||||
|
"after the container exists and listens"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_containers_use_the_databases_of_the_selected_realm() {
|
fn the_containers_use_the_databases_of_the_selected_realm() {
|
||||||
let s = Secrets {
|
let s = Secrets {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! The database of a Docker server has no log file in the server folder: MySQL writes to its container's output.
|
||||||
|
|
||||||
|
use super::cli::{Call, Docker, SystemDocker};
|
||||||
|
use super::Config;
|
||||||
|
use crate::console::Line;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The last lines MySQL wrote (what `docker logs` returns), filtered and classified like the lines of a log file.
|
||||||
|
pub fn database_log(root: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
|
||||||
|
database_log_with(&SystemDocker, root, filter, max_lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn database_log_with(d: &dyn Docker, root: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
|
||||||
|
let name = Config::load(root)?.names().db;
|
||||||
|
// Ask for more than wanted: the filter may drop most of them.
|
||||||
|
let tail = (max_lines * 4).clamp(200, 8000).to_string();
|
||||||
|
let out = d.run(&Call::new(&["logs", "--tail", &tail, &name], Duration::from_secs(20)))?;
|
||||||
|
if !out.ok() {
|
||||||
|
return Err(Error::Invalid("This log does not exist yet.".into()));
|
||||||
|
}
|
||||||
|
// MySQL writes to the error stream.
|
||||||
|
Ok(crate::console::lines_from_text(&format!("{}\n{}", out.stderr, out.stdout), filter, max_lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::docker::cli::Output;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
struct Fake {
|
||||||
|
out: Output,
|
||||||
|
calls: RefCell<Vec<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Docker for Fake {
|
||||||
|
fn run(&self, call: &Call) -> Result<Output> {
|
||||||
|
self.calls.borrow_mut().push(call.args.clone());
|
||||||
|
Ok(self.out.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let root = d.path().join("srv");
|
||||||
|
std::fs::create_dir_all(root.join("Settings")).unwrap();
|
||||||
|
std::fs::write(root.join("Settings/docker.json"), r#"{"project":"t1"}"#).unwrap();
|
||||||
|
(d, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_database_log_is_what_the_container_printed() {
|
||||||
|
let (_d, root) = root();
|
||||||
|
let fake = Fake { out: Output { code: Some(0), stdout: "2026-10-04T10:00:00Z [System] ready\n".into(), stderr: "2026-10-04T10:00:01Z [ERROR] [MY-010] boom\n2026-10-04T10:00:02Z [Warning] slow\n".into() }, calls: Default::default() };
|
||||||
|
let lines = database_log_with(&fake, &root, None, 100).unwrap();
|
||||||
|
assert_eq!(fake.calls.borrow()[0][..2], ["logs", "--tail"]);
|
||||||
|
assert_eq!(fake.calls.borrow()[0].last().unwrap(), "coa-t1-db");
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert!(lines.iter().any(|l| l.text.contains("boom") && l.level == crate::console::Level::Error));
|
||||||
|
let only = database_log_with(&fake, &root, Some("slow"), 100).unwrap();
|
||||||
|
assert_eq!(only.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_database_that_never_ran_has_no_log_yet() {
|
||||||
|
let (_d, root) = root();
|
||||||
|
let fake = Fake { out: Output { code: Some(1), stdout: String::new(), stderr: "Error: No such container: coa-t1-db".into() }, calls: Default::default() };
|
||||||
|
assert!(database_log_with(&fake, &root, None, 10).unwrap_err().to_string().contains("does not exist yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,13 @@ mod cli;
|
|||||||
pub mod fixture;
|
pub mod fixture;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod sharing;
|
||||||
|
|
||||||
pub use cli::{Call, Docker, Output, SystemDocker};
|
pub use cli::{Call, Docker, Output, SystemDocker};
|
||||||
pub(crate) use lifecycle::destroy;
|
pub(crate) use lifecycle::destroy;
|
||||||
pub use lifecycle::{check_docker, observe, observe_with, run, run_with};
|
pub use lifecycle::{check_docker, observe, observe_with, run, run_with};
|
||||||
|
pub use sharing::{bind_is_open, exposure, set_open};
|
||||||
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -170,6 +173,103 @@ pub fn ensure_main_configs(root: &Path) -> Result<Vec<String>> {
|
|||||||
Ok(created)
|
Ok(created)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings the Docker backend gives the containers as environment variables. The core reads `AC_<KEY>` before the
|
||||||
|
/// configuration file, so a value written to the file for one of these keys is silently ignored. They are what makes the
|
||||||
|
/// containers work (where things are inside them, listening on every interface, the console, no built-in database
|
||||||
|
/// updater, the databases); the owner has no use for them and the screens do not offer them.
|
||||||
|
pub(crate) const FORCED_KEYS: [&str; 12] = [
|
||||||
|
"BindIP",
|
||||||
|
"WorldServerPort",
|
||||||
|
"RealmServerPort",
|
||||||
|
"LogsDir",
|
||||||
|
"DataDir",
|
||||||
|
"Updates.EnableDatabases",
|
||||||
|
"Ra.Enable",
|
||||||
|
"Ra.IP",
|
||||||
|
"Ra.Port",
|
||||||
|
"LoginDatabaseInfo",
|
||||||
|
"WorldDatabaseInfo",
|
||||||
|
"CharacterDatabaseInfo",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Is this setting fixed by the Docker backend (see `FORCED_KEYS`)?
|
||||||
|
pub fn managed_setting(key: &str) -> bool {
|
||||||
|
FORCED_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The environment variable the core reads for a setting: `AC_` and the key in upper snake case, a `_` between a lower-case
|
||||||
|
/// letter and a capital, and at letter / digit boundaries (the core's `IniKeyToEnvVarKey`).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn env_name(key: &str) -> String {
|
||||||
|
let chars: Vec<char> = key.chars().collect();
|
||||||
|
let mut out = String::from("AC_");
|
||||||
|
for (i, &c) in chars.iter().enumerate() {
|
||||||
|
if matches!(c, ' ' | '.' | '-') {
|
||||||
|
out.push('_');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(c.to_ascii_uppercase());
|
||||||
|
if let Some(&next) = chars.get(i + 1) {
|
||||||
|
let split = (!c.is_ascii_uppercase() && next.is_ascii_uppercase())
|
||||||
|
|| (!c.is_ascii_digit() && next.is_ascii_digit())
|
||||||
|
|| (c.is_ascii_digit()
|
||||||
|
&& !next.is_ascii_digit()
|
||||||
|
&& !matches!(next, ' ' | '.' | '-'));
|
||||||
|
if split {
|
||||||
|
out.push('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder mounted as the game data of this Docker server.
|
||||||
|
pub fn game_data_dir(root: &Path) -> Result<std::path::PathBuf> {
|
||||||
|
Ok(Config::load(root)?.data_path(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point a Docker server at another game data folder (used in place, read-only). The containers are recreated at the next
|
||||||
|
/// start, so the servers must be stopped: a running one keeps the folder it has.
|
||||||
|
pub fn set_game_data(root: &Path, path: &Path) -> Result<()> {
|
||||||
|
let problems = install::data_problems(path);
|
||||||
|
if !problems.is_empty() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
problems
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.message.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observed = crate::process::observe(root, &crate::layout::read_ports(root));
|
||||||
|
let busy = |s: &crate::process::ServiceStatus| {
|
||||||
|
matches!(
|
||||||
|
s.state,
|
||||||
|
crate::process::ServiceState::Running
|
||||||
|
| crate::process::ServiceState::Starting
|
||||||
|
| crate::process::ServiceState::Stopping
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if busy(&observed.world) || busy(&observed.auth) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Stop the server before changing the game data folder.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut cfg = Config::load(root)?;
|
||||||
|
cfg.data_dir = Some(path.to_string_lossy().into_owned());
|
||||||
|
cfg.validate()?;
|
||||||
|
fsx::atomic_write_json(&root.join(MARKER), &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only
|
||||||
|
/// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it.
|
||||||
|
pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> {
|
||||||
|
let raw = raw.trim().trim_matches('"');
|
||||||
|
let outside = Path::new(raw).is_absolute() && !raw.starts_with("/srv/core/");
|
||||||
|
(key.eq_ignore_ascii_case("CoaBots.TalentBuildsPath") && !raw.is_empty() && outside)
|
||||||
|
.then_some("must be a path inside the server's Core folder (for example reference/ascensionsidekick-level-builds.json), because the server runs in a container that cannot see other folders")
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image.
|
/// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image.
|
||||||
pub(crate) fn runtime_image() -> String {
|
pub(crate) fn runtime_image() -> String {
|
||||||
format!(
|
format!(
|
||||||
@@ -287,6 +387,89 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn the_game_data_folder_can_be_changed_when_the_servers_are_stopped() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let root = d.path().join("srv");
|
||||||
|
std::fs::create_dir_all(root.join("Settings")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.join(MARKER),
|
||||||
|
r#"{"project":"t1","dataDir":"/old/data"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let good = d.path().join("data");
|
||||||
|
std::fs::create_dir_all(good.join("dbc")).unwrap();
|
||||||
|
std::fs::create_dir_all(good.join("maps")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
game_data_dir(&root).unwrap(),
|
||||||
|
std::path::PathBuf::from("/old/data")
|
||||||
|
);
|
||||||
|
set_game_data(&root, &good).unwrap();
|
||||||
|
assert_eq!(game_data_dir(&root).unwrap(), good);
|
||||||
|
assert_eq!(
|
||||||
|
Config::load(&root).unwrap().project,
|
||||||
|
"t1",
|
||||||
|
"the other settings are kept"
|
||||||
|
);
|
||||||
|
// A folder that does not look like game data is refused and nothing changes.
|
||||||
|
let empty = d.path().join("empty");
|
||||||
|
std::fs::create_dir_all(&empty).unwrap();
|
||||||
|
assert!(set_game_data(&root, &empty).is_err());
|
||||||
|
assert_eq!(game_data_dir(&root).unwrap(), good);
|
||||||
|
assert!(set_game_data(&root, std::path::Path::new("relative/dir")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() {
|
||||||
|
let key = "CoaBots.TalentBuildsPath";
|
||||||
|
assert!(setting_problem(key, "reference/builds.json").is_none());
|
||||||
|
assert!(setting_problem(key, "").is_none());
|
||||||
|
assert!(setting_problem(key, "\"/srv/core/reference/builds.json\"").is_none());
|
||||||
|
assert!(setting_problem(key, "/home/ana/builds.json").is_some());
|
||||||
|
assert!(
|
||||||
|
setting_problem("Rate.XP.Kill", "/home/ana/x").is_none(),
|
||||||
|
"only settings that name a path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setting_names_become_the_variables_the_core_reads() {
|
||||||
|
for (key, var) in [
|
||||||
|
("BindIP", "AC_BIND_IP"),
|
||||||
|
("Ra.Enable", "AC_RA_ENABLE"),
|
||||||
|
("Ra.IP", "AC_RA_IP"),
|
||||||
|
("Updates.EnableDatabases", "AC_UPDATES_ENABLE_DATABASES"),
|
||||||
|
("WorldServerPort", "AC_WORLD_SERVER_PORT"),
|
||||||
|
("LoginDatabaseInfo", "AC_LOGIN_DATABASE_INFO"),
|
||||||
|
("DataDir", "AC_DATA_DIR"),
|
||||||
|
(
|
||||||
|
"Dynamic.XP.Reminder.Interval",
|
||||||
|
"AC_DYNAMIC_XP_REMINDER_INTERVAL",
|
||||||
|
),
|
||||||
|
("EtherealBazaar.Enable", "AC_ETHEREAL_BAZAAR_ENABLE"),
|
||||||
|
] {
|
||||||
|
assert_eq!(env_name(key), var, "{key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_settings_called_managed_are_exactly_the_variables_the_containers_get() {
|
||||||
|
// If a variable is added to the containers without being listed here (or the other way round), a screen would
|
||||||
|
// offer a setting that does nothing, or hide one that works.
|
||||||
|
let given: std::collections::BTreeSet<String> =
|
||||||
|
lifecycle::forced_variables().into_iter().collect();
|
||||||
|
let listed: std::collections::BTreeSet<String> =
|
||||||
|
FORCED_KEYS.iter().map(|k| env_name(k)).collect();
|
||||||
|
assert_eq!(given, listed);
|
||||||
|
assert!(
|
||||||
|
managed_setting("ra.enable")
|
||||||
|
&& managed_setting("Updates.EnableDatabases")
|
||||||
|
&& !managed_setting("PlayerLimit")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_main_configs_are_created_from_their_templates_and_never_overwritten() {
|
fn the_main_configs_are_created_from_their_templates_and_never_overwritten() {
|
||||||
let d = tempfile::tempdir().unwrap();
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Sharing a Docker server with friends. Who can reach the game is decided by the address the container ports are
|
||||||
|
//! published on (`bindAddress` in `Settings/docker.json`): `127.0.0.1` is this computer only, anything else is the network.
|
||||||
|
//! The `BindIP` of the configuration files is forced to `0.0.0.0` inside the containers and changes nothing, so the
|
||||||
|
//! repack's way of opening the servers (editing `BindIP`) is replaced here. The database is never published and the server
|
||||||
|
//! console is only ever published on the loopback address.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::{Config, MARKER};
|
||||||
|
use crate::config::parser::ConfFile;
|
||||||
|
use crate::config::{take_snapshot, Scope};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::fsx;
|
||||||
|
use crate::layout::Ports;
|
||||||
|
use crate::net::Exposure;
|
||||||
|
use crate::process::{ServiceState, ServiceStatus};
|
||||||
|
|
||||||
|
fn is_open(cfg: &Config) -> bool {
|
||||||
|
cfg.bind_address.parse::<IpAddr>().map(|ip| !ip.is_loopback()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the login and world servers are published beyond this computer.
|
||||||
|
pub fn bind_is_open(root: &Path) -> bool {
|
||||||
|
Config::load(root).map(|c| is_open(&c)).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the login and world servers beyond this computer (`0.0.0.0`) or only on it (`127.0.0.1`). An address the owner chose
|
||||||
|
/// by hand (for example one network card) is kept when opening. Returns true if anything changed, which needs a restart of
|
||||||
|
/// the containers. The old files are snapshotted.
|
||||||
|
pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
|
||||||
|
let mut cfg = Config::load(root)?;
|
||||||
|
let mut originals: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
||||||
|
let mut changed = false;
|
||||||
|
if is_open(&cfg) != open {
|
||||||
|
originals.push((root.join(MARKER), fs::read(root.join(MARKER))?));
|
||||||
|
cfg.bind_address = if open { "0.0.0.0" } else { "127.0.0.1" }.into();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Friends connect from other addresses, and the server only applies the CoA client protocol to loopback unless remote clients
|
||||||
|
// are allowed; opening switches that on (closing leaves it, it only matters while the servers listen beyond this computer).
|
||||||
|
let coa_conf = root.join("Core/configs/modules/coa.conf");
|
||||||
|
let mut coa_edit = None;
|
||||||
|
if open {
|
||||||
|
if let Ok(bytes) = fs::read(&coa_conf) {
|
||||||
|
let mut conf = ConfFile::parse_bytes(&bytes)?;
|
||||||
|
if conf.get("CoA.AllowRemoteClients").map(str::trim) != Some("1") {
|
||||||
|
conf.set("CoA.AllowRemoteClients", "1", &["Set by CoA Server Manager together with the friends mode"]);
|
||||||
|
originals.push((coa_conf.clone(), bytes));
|
||||||
|
coa_edit = Some(conf.to_text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed && coa_edit.is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
take_snapshot(meta, Scope::Server, if open { "before opening the servers to friends" } else { "before closing the servers to friends" }, &originals)?;
|
||||||
|
if changed {
|
||||||
|
fsx::atomic_write_json(&root.join(MARKER), &cfg)?;
|
||||||
|
}
|
||||||
|
let remote_changed = coa_edit.is_some();
|
||||||
|
if let Some(text) = coa_edit {
|
||||||
|
fsx::atomic_write(&coa_conf, text.as_bytes())?;
|
||||||
|
}
|
||||||
|
Ok(changed || remote_changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What each service can be reached from, computed from how the containers publish their ports: the database is never published,
|
||||||
|
/// the console only on the loopback address, the game servers on `bindAddress`.
|
||||||
|
pub fn exposure(root: &Path, ports: &Ports) -> Vec<Exposure> {
|
||||||
|
let open = bind_is_open(root);
|
||||||
|
let observed = crate::process::observe(root, ports);
|
||||||
|
let up = |s: &ServiceStatus| matches!(s.state, ServiceState::Running | ServiceState::Starting);
|
||||||
|
vec![
|
||||||
|
Exposure { port: ports.mysql, what: "database", listening: up(&observed.mysql), reachable_from_network: false },
|
||||||
|
Exposure { port: ports.ra, what: "server console", listening: up(&observed.world), reachable_from_network: false },
|
||||||
|
Exposure { port: ports.auth, what: "login server", listening: up(&observed.auth), reachable_from_network: open && up(&observed.auth) },
|
||||||
|
Exposure { port: ports.world, what: "game world", listening: up(&observed.world), reachable_from_network: open && up(&observed.world) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn server(bind: Option<&str>) -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let root = d.path().join("srv");
|
||||||
|
let meta = d.path().join("srv.manager");
|
||||||
|
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||||
|
fs::create_dir_all(root.join("Core/configs/modules")).unwrap();
|
||||||
|
fs::create_dir_all(&meta).unwrap();
|
||||||
|
let extra = bind.map(|b| format!(r#","bindAddress":"{b}""#)).unwrap_or_default();
|
||||||
|
fs::write(root.join(MARKER), format!(r#"{{"project":"t1"{extra}}}"#)).unwrap();
|
||||||
|
fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\n").unwrap();
|
||||||
|
(d, root, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_and_closing_changes_the_published_address_and_allows_remote_clients() {
|
||||||
|
let (_d, root, meta) = server(None);
|
||||||
|
assert!(!bind_is_open(&root));
|
||||||
|
assert!(set_open(&root, &meta, true).unwrap(), "a restart is needed");
|
||||||
|
assert!(bind_is_open(&root));
|
||||||
|
assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0");
|
||||||
|
assert!(fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap().contains("CoA.AllowRemoteClients = 1"));
|
||||||
|
assert!(!set_open(&root, &meta, true).unwrap(), "nothing left to change");
|
||||||
|
assert!(set_open(&root, &meta, false).unwrap());
|
||||||
|
assert!(!bind_is_open(&root));
|
||||||
|
assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabling_remote_client_protocol_requires_restart_even_when_ports_are_open() {
|
||||||
|
let (_d, root, meta) = server(Some("0.0.0.0"));
|
||||||
|
fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\nCoA.AllowRemoteClients = 0\n").unwrap();
|
||||||
|
assert!(set_open(&root, &meta, true).unwrap());
|
||||||
|
assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0");
|
||||||
|
assert!(!set_open(&root, &meta, true).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() {
|
||||||
|
let (_d, root, meta) = server(Some("192.168.1.20"));
|
||||||
|
assert!(bind_is_open(&root), "any address but the loopback is the network");
|
||||||
|
set_open(&root, &meta, true).unwrap();
|
||||||
|
let cfg = Config::load(&root).unwrap();
|
||||||
|
assert_eq!(cfg.bind_address, "192.168.1.20");
|
||||||
|
assert_eq!(cfg.project, "t1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_old_settings_are_snapshotted_before_a_change() {
|
||||||
|
let (_d, root, meta) = server(None);
|
||||||
|
set_open(&root, &meta, true).unwrap();
|
||||||
|
assert_eq!(crate::config::list_snapshots(&meta).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_database_and_the_console_are_never_reported_reachable() {
|
||||||
|
let (_d, root, _meta) = server(Some("0.0.0.0"));
|
||||||
|
let rows = exposure(&root, &Ports::default());
|
||||||
|
let by = |what: &str| rows.iter().find(|e| e.what == what).unwrap();
|
||||||
|
assert!(!by("database").reachable_from_network && !by("server console").reachable_from_network);
|
||||||
|
// Nothing runs in this test (docker is not asked to start anything), so nothing is reachable yet.
|
||||||
|
assert!(rows.iter().all(|e| !e.reachable_from_network));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,12 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> {
|
|||||||
|
|
||||||
/// Run a launcher verb to completion (blocking; call from a worker thread).
|
/// Run a launcher verb to completion (blocking; call from a worker thread).
|
||||||
pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
||||||
run_inner(root, verb, false)
|
tracing::info!(?verb, root = %root.display(), "driver: launcher requested");
|
||||||
|
let result = run_inner(root, verb, false);
|
||||||
|
if let Err(error) = &result {
|
||||||
|
tracing::warn!(?verb, root = %root.display(), %error, "driver: launcher request failed before completion");
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_update(root: &Path) -> Result<DriverOutcome> {
|
pub(crate) fn validate_update(root: &Path) -> Result<DriverOutcome> {
|
||||||
@@ -120,8 +125,10 @@ pub(crate) fn validate_update(root: &Path) -> Result<DriverOutcome> {
|
|||||||
fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverOutcome> {
|
fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverOutcome> {
|
||||||
let root = fsx::canonicalize_lenient(root)?;
|
let root = fsx::canonicalize_lenient(root)?;
|
||||||
let _update_lock = if !validating_update && matches!(verb, Verb::StartAll | Verb::StartWorld) {
|
let _update_lock = if !validating_update && matches!(verb, Verb::StartAll | Verb::StartWorld) {
|
||||||
Some(crate::update::operation_lock(
|
Some(crate::update::operation_lock_for(
|
||||||
&crate::registry::metadata_dir_for(&root)?,
|
&crate::registry::metadata_dir_for(&root)?,
|
||||||
|
"starting server",
|
||||||
|
true,
|
||||||
)?)
|
)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -176,6 +183,9 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
discard_damaged_launcher_state(&root);
|
discard_damaged_launcher_state(&root);
|
||||||
|
if matches!(verb, Verb::StopAll | Verb::StartMysql | Verb::StartAll) {
|
||||||
|
recover_mysql_launcher_state(&root, verb != Verb::StopAll)?;
|
||||||
|
}
|
||||||
let mut cmd = Command::new(&python);
|
let mut cmd = Command::new(&python);
|
||||||
cmd.arg("-B")
|
cmd.arg("-B")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
@@ -219,7 +229,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
|||||||
};
|
};
|
||||||
let mut output = out_t.join().unwrap_or_default();
|
let mut output = out_t.join().unwrap_or_default();
|
||||||
output.push_str(&err_t.join().unwrap_or_default());
|
output.push_str(&err_t.join().unwrap_or_default());
|
||||||
let output = output.trim().to_string();
|
let mut output = output.trim().to_string();
|
||||||
let (ok, exit_code) = match status {
|
let (ok, exit_code) = match status {
|
||||||
Some(st) => (st.success(), st.code()),
|
Some(st) => (st.success(), st.code()),
|
||||||
None => (false, None),
|
None => (false, None),
|
||||||
@@ -240,6 +250,22 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
|||||||
other => Some(other),
|
other => Some(other),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if code == Some(ErrorCode::PortInUse) {
|
||||||
|
let observed = crate::process::observe(&root, &crate::layout::read_ports(&root));
|
||||||
|
for service in [&observed.mysql, &observed.auth, &observed.world]
|
||||||
|
.into_iter()
|
||||||
|
.chain(observed.secondary_world.iter())
|
||||||
|
{
|
||||||
|
if let Some(conflict) = &service.conflict {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"\nPort {} is used by process {} ({}).",
|
||||||
|
conflict.port,
|
||||||
|
conflict.pid,
|
||||||
|
conflict.exe.as_deref().unwrap_or("executable unavailable")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tracing::info!(?verb, ok, ?code, "driver: launcher verb finished");
|
tracing::info!(?verb, ok, ?code, "driver: launcher verb finished");
|
||||||
if !ok && !output.is_empty() {
|
if !ok && !output.is_empty() {
|
||||||
let redacted = crate::diag::redact(&output);
|
let redacted = crate::diag::redact(&output);
|
||||||
@@ -317,7 +343,57 @@ pub(crate) fn discard_damaged_launcher_state(root: &Path) -> Vec<PathBuf> {
|
|||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
const LAUNCH_SCRIPT: &str = "import runpy,sys;from pathlib import Path;script=sys.argv[1];sys.path.insert(0,str(Path(script).resolve().parent));sys.argv=sys.argv[1:];runpy.run_path(script,run_name='__main__')";
|
/// A lost launcher record must not hide this installation's running MySQL from
|
||||||
|
/// graceful shutdown. Adopt only the exact executable owning its configured port.
|
||||||
|
fn recover_mysql_launcher_state(root: &Path, prefer_listener: bool) -> Result<()> {
|
||||||
|
let observed = crate::process::observe(root, &crate::layout::read_ports(root));
|
||||||
|
if observed.mysql.state != crate::process::ServiceState::Running || !observed.mysql.port_ready {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(pid) = observed.mysql.pid else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(identity) = crate::process::identity(pid) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let (_, _, executable) = crate::layout::executables(root);
|
||||||
|
if !crate::process::find_by_exe(&executable)
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.same_as(&identity))
|
||||||
|
|| !crate::process::listeners().contains(&(observed.mysql.port, pid))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(saved) = crate::process::read_state_record(root, "mysql") {
|
||||||
|
if saved.same_as(&identity) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if crate::process::is_alive(&saved) {
|
||||||
|
// A valid record may describe the MySQL supervisor rather than its
|
||||||
|
// listening child. The launcher can gracefully stop either record.
|
||||||
|
if crate::process::find_by_exe(&executable)
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.same_as(&saved))
|
||||||
|
{
|
||||||
|
if !prefer_listener {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(Error::Invalid("The MySQL launcher record identifies a different live process. No database action was requested.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = root.join(".state/mysql.json");
|
||||||
|
let previous = root.join(".state/mysql-before-shutdown-recovery.json");
|
||||||
|
if state.is_file() && !previous.exists() {
|
||||||
|
fsx::atomic_write(&previous, &std::fs::read(&state)?)?;
|
||||||
|
}
|
||||||
|
fsx::atomic_write_json(&state, &identity)?;
|
||||||
|
tracing::warn!(pid, root = %root.display(), "Recovered MySQL launcher identity from the installed executable owning its port");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUNCH_SCRIPT: &str = "import runpy,sys\nfrom pathlib import Path\nscript=sys.argv[1]\nsys.path.insert(0,str(Path(script).resolve().parent))\nsys.argv=sys.argv[1:]\nif sys.argv[1]=='stop-all':\n launcher=runpy.run_path(script,run_name='coa_manager_shutdown')\n config=launcher['settings']()\n with launcher['control_lock']():\n launcher['stop_all'](config)\nelse:\n runpy.run_path(script,run_name='__main__')";
|
||||||
|
|
||||||
pub(crate) fn patch_launcher_imports(text: &str) -> Result<String> {
|
pub(crate) fn patch_launcher_imports(text: &str) -> Result<String> {
|
||||||
let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))";
|
let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))";
|
||||||
@@ -381,20 +457,100 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String {
|
|||||||
.unwrap_or("The server did not become ready");
|
.unwrap_or("The server did not become ready");
|
||||||
let output = crate::diag::redact(&started.output);
|
let output = crate::diag::redact(&started.output);
|
||||||
let lines: Vec<_> = output.lines().rev().take(12).collect();
|
let lines: Vec<_> = output.lines().rev().take(12).collect();
|
||||||
if lines.is_empty() {
|
let mut reason = if lines.is_empty() {
|
||||||
title.into()
|
title.into()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{title}\n{}",
|
"{title}\n{}",
|
||||||
lines.into_iter().rev().collect::<Vec<_>>().join("\n")
|
lines.into_iter().rev().collect::<Vec<_>>().join("\n")
|
||||||
)
|
)
|
||||||
|
};
|
||||||
|
for relative in [
|
||||||
|
"Core/Logs/world-console.log",
|
||||||
|
"Core/Logs/auth-console.log",
|
||||||
|
"mysql/logs/mysql-error.log",
|
||||||
|
] {
|
||||||
|
let tail = crate::health::tail(&root.join(relative), 64 * 1024);
|
||||||
|
let failures: Vec<_> = tail
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
let lower = line.to_ascii_lowercase();
|
||||||
|
crate::health::diagnose(line).is_some()
|
||||||
|
|| lower.contains("fatal")
|
||||||
|
|| lower.contains("error")
|
||||||
|
|| lower.contains("aborting")
|
||||||
|
})
|
||||||
|
.rev()
|
||||||
|
.take(8)
|
||||||
|
.collect();
|
||||||
|
if !failures.is_empty() {
|
||||||
|
reason.push_str(&format!(
|
||||||
|
"\n{relative}:\n{}",
|
||||||
|
crate::diag::redact(&failures.into_iter().rev().collect::<Vec<_>>().join("\n"))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
reason
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn shutdown_recovery_never_adopts_a_foreign_port_listener() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
fsx::atomic_write_json(
|
||||||
|
&root.path().join("Settings/repack.json"),
|
||||||
|
&serde_json::json!({"mysqlPort":listener.local_addr().unwrap().port()}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
recover_mysql_launcher_state(root.path(), false).unwrap();
|
||||||
|
recover_mysql_launcher_state(root.path(), true).unwrap();
|
||||||
|
assert!(!root.path().join(".state/mysql.json").exists());
|
||||||
|
assert!(crate::process::identity(std::process::id()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blocked_start_is_logged_before_any_launcher_is_spawned() {
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
struct Capture(Arc<Mutex<Vec<u8>>>);
|
||||||
|
impl Write for Capture {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("server");
|
||||||
|
std::fs::create_dir(&root).unwrap();
|
||||||
|
let metadata = crate::registry::metadata_dir_for(&root).unwrap();
|
||||||
|
let _maintenance = crate::update::operation_lock(&metadata).unwrap();
|
||||||
|
let buffer = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let writer = Arc::clone(&buffer);
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.without_time()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(move || Capture(Arc::clone(&writer)))
|
||||||
|
.finish();
|
||||||
|
let error = tracing::subscriber::with_default(subscriber, || run(&root, Verb::StartAll))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("in progress"));
|
||||||
|
let text = String::from_utf8(buffer.lock().unwrap().clone()).unwrap();
|
||||||
|
assert!(text.contains("launcher requested") && text.contains("StartAll"));
|
||||||
|
assert!(
|
||||||
|
text.contains("launcher request failed before completion")
|
||||||
|
&& text.contains("in progress")
|
||||||
|
);
|
||||||
|
assert!(!text.contains("starting launcher verb"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn background_start_disables_cli_in_template_and_rendered_config() {
|
fn background_start_disables_cli_in_template_and_rendered_config() {
|
||||||
let folder = tempfile::tempdir().unwrap();
|
let folder = tempfile::tempdir().unwrap();
|
||||||
@@ -524,6 +680,24 @@ mod tests {
|
|||||||
assert!(run(dir.path(), Verb::StartAll).is_err());
|
assert!(run(dir.path(), Verb::StartAll).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_reports_late_world_errors_after_launcher_ready() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("Core/Logs")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("Core/Logs/world-console.log"), "ERROR [1146] Table 'acore_characters.playerbots_names' doesn't exist\nFATAL Could not prepare statements\n").unwrap();
|
||||||
|
let started = DriverOutcome {
|
||||||
|
ok: true,
|
||||||
|
code: None,
|
||||||
|
exit_code: Some(0),
|
||||||
|
human: None,
|
||||||
|
output: "Worldserver and automatic bug reporting are ready.".into(),
|
||||||
|
};
|
||||||
|
let reason = startup_failure(dir.path(), &started);
|
||||||
|
assert!(reason.contains("playerbots_names"));
|
||||||
|
assert!(reason.contains("Core/Logs/world-console.log"));
|
||||||
|
assert!(reason.contains("Could not prepare statements"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_failure_keeps_the_launcher_reason_without_secrets() {
|
fn startup_failure_keeps_the_launcher_reason_without_secrets() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub enum ErrorCode {
|
|||||||
OperationInProgress,
|
OperationInProgress,
|
||||||
StartupFailed,
|
StartupFailed,
|
||||||
DockerUnavailable,
|
DockerUnavailable,
|
||||||
|
GameDataMismatch,
|
||||||
HashMismatch,
|
HashMismatch,
|
||||||
PathRejected,
|
PathRejected,
|
||||||
PackageNotPublished,
|
PackageNotPublished,
|
||||||
@@ -142,6 +143,11 @@ impl ErrorCode {
|
|||||||
"This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.",
|
"This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.",
|
||||||
&[Retry, ShowDetails],
|
&[Retry, ShowDetails],
|
||||||
),
|
),
|
||||||
|
ErrorCode::GameDataMismatch => (
|
||||||
|
"The game data folder is not the CoA set",
|
||||||
|
"The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.",
|
||||||
|
&[ShowDetails],
|
||||||
|
),
|
||||||
ErrorCode::HashMismatch => (
|
ErrorCode::HashMismatch => (
|
||||||
"A downloaded file is damaged",
|
"A downloaded file is damaged",
|
||||||
"The file did not match its checksum and was not used.",
|
"The file did not match its checksum and was not used.",
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ fn bind_targets(root: &Path) -> Vec<PathBuf> {
|
|||||||
|
|
||||||
/// Whether the configuration currently lets other computers reach the login and world servers.
|
/// Whether the configuration currently lets other computers reach the login and world servers.
|
||||||
pub fn bind_is_open(root: &Path) -> bool {
|
pub fn bind_is_open(root: &Path) -> bool {
|
||||||
|
if crate::docker::is_docker(root) {
|
||||||
|
return crate::docker::bind_is_open(root);
|
||||||
|
}
|
||||||
let files = bind_targets(root);
|
let files = bind_targets(root);
|
||||||
!files.is_empty()
|
!files.is_empty()
|
||||||
&& files.iter().all(|p| {
|
&& files.iter().all(|p| {
|
||||||
@@ -107,6 +110,9 @@ pub fn bind_is_open(root: &Path) -> bool {
|
|||||||
/// Open (`0.0.0.0`) or close (`127.0.0.1`) the login and world servers to other computers. The database and the server
|
/// Open (`0.0.0.0`) or close (`127.0.0.1`) the login and world servers to other computers. The database and the server
|
||||||
/// console are never touched. Returns true if anything changed (a restart is then needed). The old files are snapshotted.
|
/// console are never touched. Returns true if anything changed (a restart is then needed). The old files are snapshotted.
|
||||||
pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
|
pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
|
||||||
|
if crate::docker::is_docker(root) {
|
||||||
|
return crate::docker::set_open(root, meta, open);
|
||||||
|
}
|
||||||
let want = if open { "\"0.0.0.0\"" } else { "\"127.0.0.1\"" };
|
let want = if open { "\"0.0.0.0\"" } else { "\"127.0.0.1\"" };
|
||||||
let mut originals = Vec::new();
|
let mut originals = Vec::new();
|
||||||
let mut edits = Vec::new();
|
let mut edits = Vec::new();
|
||||||
@@ -155,7 +161,8 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make the realm list advertise `host` (the launcher resets it to 127.0.0.1 at every start, so this runs after each start).
|
/// Make the realm list advertise `host` to remote clients (the launcher resets it to 127.0.0.1 at every start, so this
|
||||||
|
/// runs after each start).
|
||||||
pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
||||||
if !host_ok(host) {
|
if !host_ok(host) {
|
||||||
return Err(Error::Invalid("That address is not valid.".into()));
|
return Err(Error::Invalid("That address is not valid.".into()));
|
||||||
@@ -167,7 +174,9 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
format!("id={realm}")
|
format!("id={realm}")
|
||||||
};
|
};
|
||||||
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE {where_realms};"))?;
|
db.query(&format!(
|
||||||
|
"UPDATE acore_auth.realmlist SET address='{host}' WHERE {where_realms};"
|
||||||
|
))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ pub fn normalize_lexical(path: &Path) -> PathBuf {
|
|||||||
fn key(path: &Path) -> Vec<String> {
|
fn key(path: &Path) -> Vec<String> {
|
||||||
path.components()
|
path.components()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Component::Prefix(prefix) = c {
|
||||||
|
use std::path::Prefix;
|
||||||
|
match prefix.kind() {
|
||||||
|
Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => {
|
||||||
|
return format!("{}:", char::from(drive).to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||||||
|
return format!("\\\\{}\\{}", server.to_string_lossy(), share.to_string_lossy()).to_lowercase();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
let c = c.as_os_str().to_string_lossy();
|
let c = c.as_os_str().to_string_lossy();
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
c.to_lowercase()
|
c.to_lowercase()
|
||||||
@@ -321,6 +334,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn containment_matches_verbatim_drive_and_unc_prefixes() {
|
||||||
|
assert!(starts_with_ci(Path::new(r"\\?\C:\server\Extras\file.sql"), Path::new(r"C:\server")));
|
||||||
|
assert!(starts_with_ci(Path::new(r"C:\server\Extras\file.sql"), Path::new(r"\\?\c:\server")));
|
||||||
|
assert!(starts_with_ci(Path::new(r"\\?\UNC\host\share\server\file.sql"), Path::new(r"\\host\share\server")));
|
||||||
|
assert!(!starts_with_ci(Path::new(r"\\?\D:\server\file.sql"), Path::new(r"C:\server")));
|
||||||
|
assert!(!starts_with_ci(Path::new(r"\\?\C:\server-other\file.sql"), Path::new(r"C:\server")));
|
||||||
|
assert!(!starts_with_ci(Path::new(r"\\?\UNC\host\other\server\file.sql"), Path::new(r"\\host\share\server")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn ensure_within_accepts_long_rehearsal_sql_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("server");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let mut candidate = root.clone();
|
||||||
|
for _ in 0..5 {
|
||||||
|
candidate.push("long-private-rehearsal-directory-name-20261013");
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&candidate).unwrap();
|
||||||
|
candidate.push("2026_06_13_00_ai_playerbot_missing_group_buff_reagent.sql");
|
||||||
|
fs::write(&candidate, b"SELECT 1;").unwrap();
|
||||||
|
assert!(candidate.as_os_str().len() > 260);
|
||||||
|
assert!(ensure_within(&root, &candidate).is_ok());
|
||||||
|
assert!(ensure_within(&root, &std::fs::canonicalize(candidate).unwrap()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_within_blocks_junction_escape() {
|
fn ensure_within_blocks_junction_escape() {
|
||||||
if !cfg!(windows) {
|
if !cfg!(windows) {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub fn diagnose(text: &str) -> Option<ErrorCode> {
|
|||||||
|| l.contains("only one usage of each socket")
|
|| l.contains("only one usage of each socket")
|
||||||
{
|
{
|
||||||
Some(ErrorCode::PortInUse)
|
Some(ErrorCode::PortInUse)
|
||||||
|
} else if l.contains("does not hold the coa client dbc set")
|
||||||
|
|| l.contains("_outdated_ dbc data")
|
||||||
|
{
|
||||||
|
Some(ErrorCode::GameDataMismatch)
|
||||||
} else if l.contains("config::loadfile")
|
} else if l.contains("config::loadfile")
|
||||||
&& l.contains("failed open file")
|
&& l.contains("failed open file")
|
||||||
&& l.contains("worldserver.conf")
|
&& l.contains("worldserver.conf")
|
||||||
@@ -71,6 +75,13 @@ pub fn diagnose_installation(root: &Path) -> Option<ErrorCode> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_game_data_folder_without_the_coa_dbc_set_is_named_as_such() {
|
||||||
|
let log = "Using DataDir /srv/data/\nSpellAffect.dbc not found in /srv/data/dbc/\nDataDir does not hold the CoA client DBC set. Install it with apps/coa-dbc/client_dbc.py.";
|
||||||
|
assert_eq!(diagnose(log), Some(ErrorCode::GameDataMismatch));
|
||||||
|
assert_eq!(diagnose("You have _outdated_ DBC data. Please extract correct versions from current using client."), Some(ErrorCode::GameDataMismatch));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recognises_missing_migrations() {
|
fn recognises_missing_migrations() {
|
||||||
let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date.";
|
let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date.";
|
||||||
|
|||||||
@@ -274,6 +274,10 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> {
|
|||||||
&target,
|
&target,
|
||||||
&serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }),
|
&serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }),
|
||||||
)?;
|
)?;
|
||||||
|
fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!(
|
||||||
|
"[client]\nuser=root\npassword={root_pw}\nhost=127.0.0.1\nport={}\nprotocol=tcp\ndefault-character-set=utf8mb4\n",
|
||||||
|
crate::layout::read_ports(root).mysql,
|
||||||
|
).as_bytes())?;
|
||||||
// The server console gets its own account with a random password instead of a shared default.
|
// The server console gets its own account with a random password instead of a shared default.
|
||||||
let console_pw = Db::from_repack(root, Account::Admin)?.provision_service_account()?; // new root password
|
let console_pw = Db::from_repack(root, Account::Admin)?.provision_service_account()?; // new root password
|
||||||
db::write_console_credentials(root, &console_pw)?;
|
db::write_console_credentials(root, &console_pw)?;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
pub mod allsettings;
|
pub mod allsettings;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
pub mod channels;
|
||||||
pub mod cleanbase;
|
pub mod cleanbase;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod clientdl;
|
pub mod clientdl;
|
||||||
@@ -38,6 +39,7 @@ pub mod manifest;
|
|||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod multiworld;
|
pub mod multiworld;
|
||||||
|
mod mysql_snapshot;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod package;
|
pub mod package;
|
||||||
pub mod pkgsource;
|
pub mod pkgsource;
|
||||||
@@ -60,6 +62,8 @@ pub mod signing;
|
|||||||
pub mod squid;
|
pub mod squid;
|
||||||
pub mod srp6;
|
pub mod srp6;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
mod update_isolation;
|
||||||
|
pub mod update_route;
|
||||||
pub mod upnp;
|
pub mod upnp;
|
||||||
pub mod wine;
|
pub mod wine;
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,32 @@ pub struct Revision {
|
|||||||
pub commit: Option<String>,
|
pub commit: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SourceDatabase {
|
||||||
|
pub manifest_sha256: String,
|
||||||
|
pub schema_sha256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct Compatibility {
|
||||||
|
pub schema: u32,
|
||||||
|
pub platform: String,
|
||||||
|
pub core_commit: String,
|
||||||
|
pub module_commits: std::collections::BTreeMap<String, String>,
|
||||||
|
pub client_patch_version: String,
|
||||||
|
/// Sources exercised during release qualification, not an exclusive updater allowlist.
|
||||||
|
pub source_versions: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||||
|
pub source_databases: std::collections::BTreeMap<String, SourceDatabase>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Manifest {
|
pub struct Manifest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub compatibility: Option<Compatibility>,
|
||||||
pub schema: u32,
|
pub schema: u32,
|
||||||
pub kind: Kind,
|
pub kind: Kind,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@@ -140,6 +163,26 @@ impl Manifest {
|
|||||||
self.min_manager_version
|
self.min_manager_version
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(c) = &self.compatibility {
|
||||||
|
if parse_version(&self.min_manager_version).is_none_or(|v| v < (0, 6, 13)) {
|
||||||
|
return bad("Release compatibility matrices require Manager 0.6.13 or newer".into());
|
||||||
|
}
|
||||||
|
if c.schema != 1 || !matches!(c.platform.as_str(), "windows-x86_64" | "linux-x86_64")
|
||||||
|
|| !is_commit(&c.core_commit) || self.core.commit.as_deref() != Some(&c.core_commit)
|
||||||
|
|| c.module_commits.is_empty() || c.module_commits.values().any(|sha| !is_commit(sha))
|
||||||
|
|| self.bots.as_ref().and_then(|r| r.commit.as_ref()).is_some_and(|sha| c.module_commits.get("bots") != Some(sha))
|
||||||
|
|| parse_version(&c.client_patch_version).is_none()
|
||||||
|
|| c.source_versions.is_empty() || c.source_versions.iter().any(|v| parse_version(v).is_none())
|
||||||
|
|| c.source_versions.iter().collect::<HashSet<_>>().len() != c.source_versions.len() {
|
||||||
|
return bad("Invalid release compatibility matrix".into());
|
||||||
|
}
|
||||||
|
if !c.source_databases.is_empty() && (c.source_databases.len() != c.source_versions.len()
|
||||||
|
|| c.source_versions.iter().any(|v| !c.source_databases.contains_key(v))
|
||||||
|
|| c.source_databases.values().any(|s| !is_sha256(&s.manifest_sha256)
|
||||||
|
|| s.schema_sha256.as_ref().is_some_and(|sha| !is_sha256(sha)))) {
|
||||||
|
return bad("Invalid source database compatibility".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
for migration in &self.migrations {
|
for migration in &self.migrations {
|
||||||
if !is_sha256(&migration.sha256)
|
if !is_sha256(&migration.sha256)
|
||||||
|| migration
|
|| migration
|
||||||
@@ -261,6 +304,53 @@ mod tests {
|
|||||||
assert!(!m.compatible_with_manager("0.0.9"));
|
assert!(!m.compatible_with_manager("0.0.9"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatibility_matrix_binds_core_and_requires_tested_sources() {
|
||||||
|
let mut m = Manifest::parse(sample("").as_bytes()).unwrap();
|
||||||
|
m.min_manager_version = "0.6.13".into();
|
||||||
|
m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(),
|
||||||
|
core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(),
|
||||||
|
source_databases: Default::default(), client_patch_version: "1.5.1".into(), source_versions: vec!["0.261010.32".into()] });
|
||||||
|
m.validate().unwrap();
|
||||||
|
m.compatibility.as_mut().unwrap().core_commit = "c".repeat(40);
|
||||||
|
assert!(m.validate().is_err());
|
||||||
|
m.compatibility.as_mut().unwrap().core_commit = "a".repeat(40);
|
||||||
|
m.compatibility.as_mut().unwrap().source_versions.clear();
|
||||||
|
assert!(m.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatibility_cannot_claim_a_different_packaged_bot_revision() {
|
||||||
|
let mut m = Manifest::parse(sample("").as_bytes()).unwrap();
|
||||||
|
m.min_manager_version = "0.6.13".into();
|
||||||
|
m.bots = Some(Revision { commit: Some("b".repeat(40)) });
|
||||||
|
m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(),
|
||||||
|
core_commit: "a".repeat(40), module_commits: [("bots".into(), "c".repeat(40))].into(),
|
||||||
|
source_databases: Default::default(), client_patch_version: "1.5.1".into(), source_versions: vec!["0.2.0".into()] });
|
||||||
|
assert!(m.validate().is_err());
|
||||||
|
m.compatibility.as_mut().unwrap().module_commits.insert("bots".into(), "b".repeat(40));
|
||||||
|
m.validate().unwrap();
|
||||||
|
m.compatibility.as_mut().unwrap().source_versions.push("0.2.0".into());
|
||||||
|
assert!(m.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_database_contracts_must_cover_the_exact_upgrade_versions() {
|
||||||
|
let mut m = Manifest::parse(sample("").as_bytes()).unwrap();
|
||||||
|
m.min_manager_version = "0.6.13".into();
|
||||||
|
m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(),
|
||||||
|
core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(),
|
||||||
|
client_patch_version: "1.5.1".into(), source_versions: vec!["0.2.0".into()],
|
||||||
|
source_databases: [("0.2.0".into(), SourceDatabase { manifest_sha256: "a".repeat(64), schema_sha256: None })].into() });
|
||||||
|
m.validate().unwrap();
|
||||||
|
m.compatibility.as_mut().unwrap().source_databases.get_mut("0.2.0").unwrap().schema_sha256 = Some("bad".into());
|
||||||
|
assert!(m.validate().is_err());
|
||||||
|
let c = m.compatibility.as_mut().unwrap();
|
||||||
|
c.source_databases.get_mut("0.2.0").unwrap().schema_sha256 = None;
|
||||||
|
c.source_versions = vec!["0.2.1".into()];
|
||||||
|
assert!(m.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_traversal_bad_hash_duplicates_and_short_commit() {
|
fn rejects_traversal_bad_hash_duplicates_and_short_commit() {
|
||||||
let ok = "b".repeat(64);
|
let ok = "b".repeat(64);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::fsx;
|
|||||||
use crate::manifest::Migration;
|
use crate::manifest::Migration;
|
||||||
|
|
||||||
const LEDGER_SCHEMA: &str = "acore_world";
|
const LEDGER_SCHEMA: &str = "acore_world";
|
||||||
|
const AUTH_LEDGER_SCHEMA: &str = "acore_auth";
|
||||||
const LEDGER_TABLE: &str = "coa_manager_migrations";
|
const LEDGER_TABLE: &str = "coa_manager_migrations";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
@@ -39,6 +40,12 @@ pub trait Store {
|
|||||||
self.load()
|
self.load()
|
||||||
}
|
}
|
||||||
fn put(&self, row: &LedgerRow) -> Result<()>;
|
fn put(&self, row: &LedgerRow) -> Result<()>;
|
||||||
|
fn put_batch(&self, rows: &[LedgerRow]) -> Result<()> {
|
||||||
|
for row in rows {
|
||||||
|
self.put(row)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn run_file(&self, schema: &str, file: &Path) -> Result<()>;
|
fn run_file(&self, schema: &str, file: &Path) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,102 +60,206 @@ fn ident_ok(s: &str) -> bool {
|
|||||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shared_auth(kind: &str) -> bool {
|
||||||
|
matches!(db::schema_of(kind), Ok("acore_auth"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_ledger(db: &Db, schema: &str) -> Result<()> {
|
||||||
|
db.query(&format!("CREATE TABLE IF NOT EXISTS `{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;"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl Store for Db {
|
impl Store for Db {
|
||||||
fn load(&self) -> Result<Vec<LedgerRow>> {
|
fn load(&self) -> Result<Vec<LedgerRow>> {
|
||||||
self.query(&format!(
|
create_ledger(self, LEDGER_SCHEMA)?;
|
||||||
"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_ledger(self, AUTH_LEDGER_SCHEMA)?;
|
||||||
))?;
|
let rows = self.load_readonly()?;
|
||||||
self.load_readonly()
|
// Preserve already-recorded core history even if a module later replaces its updates table.
|
||||||
|
// A zero checksum remains unknown; importing it does not invent a checksum for old SQL.
|
||||||
|
let selected: std::collections::BTreeMap<_, _> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| ((row.db.clone(), row.id.to_ascii_lowercase()), row))
|
||||||
|
.collect();
|
||||||
|
let core: Vec<_> = selected
|
||||||
|
.values()
|
||||||
|
.filter(|r| {
|
||||||
|
r.status == Status::Applied
|
||||||
|
&& r.baseline
|
||||||
|
&& r.sha256 == "0".repeat(64)
|
||||||
|
&& matches!(r.db.as_str(), "world" | "characters")
|
||||||
|
&& ident_ok(&r.id)
|
||||||
|
})
|
||||||
|
.map(|r| (*r).clone())
|
||||||
|
.collect();
|
||||||
|
self.put_batch(&core)?;
|
||||||
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_readonly(&self) -> Result<Vec<LedgerRow>> {
|
fn load_readonly(&self) -> Result<Vec<LedgerRow>> {
|
||||||
let out = if self
|
// Resolve physical schemas once, then read all history in two client calls.
|
||||||
.tables(LEDGER_SCHEMA)?
|
// Using a neutral connection prevents a Wildcard query from rerouting the other realm.
|
||||||
.iter()
|
let connection = self.clone().for_realm(crate::realms::Mode::Coa);
|
||||||
.any(|name| name == LEDGER_TABLE)
|
let world = self.realm_schema("acore_world");
|
||||||
{
|
let characters = self.realm_schema("acore_characters");
|
||||||
self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))?
|
let other = if self.realm() == crate::realms::Mode::Coa {
|
||||||
|
"acore_world_wildcard"
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
"acore_world"
|
||||||
};
|
};
|
||||||
let mut rows = Vec::new();
|
let schemas: std::collections::BTreeSet<_> = [world, characters, other, AUTH_LEDGER_SCHEMA]
|
||||||
// Files the core's own updater (or whoever prepared this database) already recorded count as applied.
|
.into_iter()
|
||||||
for (kind, schema) in db::SCHEMAS {
|
.collect();
|
||||||
if self.tables(schema)?.iter().any(|name| name == "updates") {
|
let catalog = connection.query(&format!("SELECT table_schema,table_name FROM information_schema.tables WHERE table_schema IN ('{}') AND table_name IN ('updates','{LEDGER_TABLE}') AND table_type='BASE TABLE';", schemas.into_iter().collect::<Vec<_>>().join("','")))?;
|
||||||
let names = self.query(&format!("SELECT name FROM `{schema}`.`updates`;"))?;
|
let available: std::collections::BTreeSet<_> = catalog
|
||||||
for name in names.lines().filter(|n| n.ends_with(".sql")) {
|
.lines()
|
||||||
rows.push(LedgerRow {
|
.filter_map(|line| line.split_once('\t'))
|
||||||
db: kind.into(),
|
.collect();
|
||||||
id: name.trim_end_matches(".sql").into(),
|
let text =
|
||||||
sha256: "0".repeat(64),
|
|expression: &str| format!("CONVERT({expression} USING utf8mb4) COLLATE utf8mb4_bin");
|
||||||
status: Status::Applied,
|
let mut queries = Vec::new();
|
||||||
error: None,
|
for (kind, schema) in [
|
||||||
baseline: true,
|
("characters", characters),
|
||||||
});
|
("auth", AUTH_LEDGER_SCHEMA),
|
||||||
}
|
("world", world),
|
||||||
|
] {
|
||||||
|
if available.contains(&(schema, "updates")) {
|
||||||
|
queries.push(format!("SELECT 'core','{kind}',{},REPEAT('0',64),'applied',1,'' FROM `{schema}`.`updates` WHERE BINARY RIGHT(name,4)='.sql'",text("LEFT(name,CHAR_LENGTH(name)-4)")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (origin, schema, filter) in [
|
||||||
|
("local", world, ""),
|
||||||
|
(
|
||||||
|
"other",
|
||||||
|
other,
|
||||||
|
" WHERE `db` IN ('auth','coa-auth','wildcard-auth')",
|
||||||
|
),
|
||||||
|
("shared", AUTH_LEDGER_SCHEMA, ""),
|
||||||
|
] {
|
||||||
|
if available.contains(&(schema, LEDGER_TABLE)) {
|
||||||
|
queries.push(format!("SELECT '{origin}',{},{},{},{},`baseline`,{} FROM `{schema}`.`{LEDGER_TABLE}`{filter}",text("`db`"),text("`id`"),text("`sha256`"),text("`status`"),text("COALESCE(HEX(`error`),'')")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if queries.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let out = connection.query(&queries.join(" UNION ALL "))?;
|
||||||
|
let (mut rows, mut local, mut auth, mut shared) =
|
||||||
|
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||||
for line in out.lines().filter(|l| !l.is_empty()) {
|
for line in out.lines().filter(|l| !l.is_empty()) {
|
||||||
let f: Vec<&str> = line.split('\t').collect();
|
let f: Vec<_> = line.split('\t').collect();
|
||||||
if f.len() < 5 {
|
if f.len() < 6 {
|
||||||
return Err(Error::Invalid(
|
return Err(Error::Invalid(
|
||||||
"The database migration history is malformed; no SQL was replayed.".into(),
|
"The database migration history is malformed; no SQL was replayed.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !matches!(f[3], "applied" | "failed" | "running")
|
// Unrelated legacy filenames cannot match a valid manifest migration ID.
|
||||||
|| !ident_ok(f[1])
|
if f[0] == "core" && !ident_ok(f[2]) {
|
||||||
|| f[2].len() != 64
|
continue;
|
||||||
|| !f[2].chars().all(|c| c.is_ascii_hexdigit())
|
}
|
||||||
|
if !matches!(f[4], "applied" | "failed" | "running")
|
||||||
|
|| !ident_ok(f[2])
|
||||||
|
|| f[3].len() != 64
|
||||||
|
|| !f[3].chars().all(|c| c.is_ascii_hexdigit())
|
||||||
{
|
{
|
||||||
return Err(Error::Invalid(
|
return Err(Error::Invalid(
|
||||||
"The database migration history is invalid; no SQL was replayed.".into(),
|
"The database migration history is invalid; no SQL was replayed.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let err = f
|
let error = f
|
||||||
.get(5)
|
.get(6)
|
||||||
.filter(|h| !h.is_empty())
|
.filter(|h| !h.is_empty())
|
||||||
.and_then(|h| hex::decode(h).ok())
|
.and_then(|h| hex::decode(h).ok())
|
||||||
.map(|b| String::from_utf8_lossy(&b).into_owned());
|
.map(|b| String::from_utf8_lossy(&b).into_owned());
|
||||||
rows.push(LedgerRow {
|
let row = LedgerRow {
|
||||||
db: f[0].into(),
|
db: f[1].into(),
|
||||||
id: f[1].into(),
|
id: f[2].into(),
|
||||||
sha256: f[2].into(),
|
sha256: f[3].into(),
|
||||||
status: match f[3] {
|
status: match f[4] {
|
||||||
"applied" => Status::Applied,
|
"applied" => Status::Applied,
|
||||||
"running" => Status::Running,
|
"running" => Status::Running,
|
||||||
_ => Status::Failed,
|
_ => Status::Failed,
|
||||||
},
|
},
|
||||||
baseline: f[4] == "1",
|
baseline: f[5] == "1",
|
||||||
error: err,
|
error,
|
||||||
});
|
};
|
||||||
|
match f[0] {
|
||||||
|
"core" => rows.push(row),
|
||||||
|
"local" if !shared_auth(&row.db) => local.push(row),
|
||||||
|
"local" | "other" => auth.push(row),
|
||||||
|
"shared" => shared.push(row),
|
||||||
|
_ => return Err(Error::Invalid("Unknown migration history source.".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.extend(local);
|
||||||
|
// An interrupted legacy auth execution must not be hidden by another realm's applied row.
|
||||||
|
auth.sort_by_key(|r| {
|
||||||
|
(
|
||||||
|
match r.status {
|
||||||
|
Status::Applied => 0,
|
||||||
|
Status::Running => 1,
|
||||||
|
Status::Failed => 2,
|
||||||
|
Status::Pending => 3,
|
||||||
|
},
|
||||||
|
r.sha256 != "0".repeat(64),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
for mut row in auth {
|
||||||
|
row.db = "auth".into();
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
// New auth records live with the shared account database and are authoritative.
|
||||||
|
for mut row in shared {
|
||||||
|
if !shared_auth(&row.db) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"The shared authentication migration history is invalid.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rows.retain(|r| !(shared_auth(&r.db) && r.id.eq_ignore_ascii_case(&row.id)));
|
||||||
|
row.db = "auth".into();
|
||||||
|
rows.push(row);
|
||||||
}
|
}
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put(&self, r: &LedgerRow) -> Result<()> {
|
fn put(&self, row: &LedgerRow) -> Result<()> {
|
||||||
if !ident_ok(&r.id)
|
self.put_batch(std::slice::from_ref(row))
|
||||||
|| !ident_ok(&r.db)
|
}
|
||||||
|| r.sha256.len() != 64
|
|
||||||
|| !r.sha256.chars().all(|c| c.is_ascii_hexdigit())
|
fn put_batch(&self, rows: &[LedgerRow]) -> Result<()> {
|
||||||
{
|
let mut groups = std::collections::BTreeMap::<&str, Vec<String>>::new();
|
||||||
return Err(Error::Invalid(
|
for r in rows {
|
||||||
"migration record has an invalid identifier".into(),
|
if !ident_ok(&r.id)
|
||||||
|
|| !ident_ok(&r.db)
|
||||||
|
|| r.sha256.len() != 64
|
||||||
|
|| !r.sha256.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"migration record has an invalid identifier".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let status = match r.status {
|
||||||
|
Status::Applied => "applied",
|
||||||
|
Status::Running => "running",
|
||||||
|
_ => "failed",
|
||||||
|
};
|
||||||
|
let error = r
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.map(|e| format!("UNHEX('{}')", hex_of(e)))
|
||||||
|
.unwrap_or_else(|| "NULL".into());
|
||||||
|
let (schema, kind) = if shared_auth(&r.db) {
|
||||||
|
(AUTH_LEDGER_SCHEMA, "auth")
|
||||||
|
} else {
|
||||||
|
(LEDGER_SCHEMA, r.db.as_str())
|
||||||
|
};
|
||||||
|
groups.entry(schema).or_default().push(format!(
|
||||||
|
"('{}','{}','{}','{status}',NOW(),{error},{})",
|
||||||
|
kind, r.id, r.sha256, r.baseline as u8
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let status = match r.status {
|
for (schema, values) in groups {
|
||||||
Status::Applied => "applied",
|
self.query(&format!("REPLACE INTO `{schema}`.`{LEDGER_TABLE}` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES {};",values.join(",")))?;
|
||||||
Status::Running => "running",
|
}
|
||||||
_ => "failed",
|
|
||||||
};
|
|
||||||
let err = r
|
|
||||||
.error
|
|
||||||
.as_deref()
|
|
||||||
.map(|e| format!("UNHEX('{}')", hex_of(e)))
|
|
||||||
.unwrap_or_else(|| "NULL".into());
|
|
||||||
self.query(&format!(
|
|
||||||
"REPLACE INTO `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES ('{}','{}','{}','{status}',NOW(),{err},{});",
|
|
||||||
r.db, r.id, r.sha256, r.baseline as u8
|
|
||||||
))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,21 +308,47 @@ pub fn status(store: &dyn Store, list: &[Migration]) -> Result<Vec<Item>> {
|
|||||||
.map(|m| {
|
.map(|m| {
|
||||||
// Manager records are appended after the core's updates table and are authoritative.
|
// Manager records are appended after the core's updates table and are authoritative.
|
||||||
let row = recorded(&rows, m);
|
let row = recorded(&rows, m);
|
||||||
let changed = row.is_some_and(|r| hash_changed(r, m));
|
let history = effective_history(&rows, m);
|
||||||
|
let changed = history.iter().any(|r| hash_changed(r,m));
|
||||||
|
let interrupted = history.iter().find(|r| matches!(r.status, Status::Running | Status::Failed));
|
||||||
Item {
|
Item {
|
||||||
id: m.id.clone(),
|
id: m.id.clone(),
|
||||||
db: m.db.clone(),
|
db: m.db.clone(),
|
||||||
status: if changed || row.is_some_and(|r| r.status == Status::Running) { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) },
|
status: if changed || interrupted.is_some() { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) },
|
||||||
destructive: m.destructive,
|
destructive: m.destructive,
|
||||||
baseline: row.map(|r| r.baseline).unwrap_or(false),
|
baseline: row.map(|r| r.baseline).unwrap_or(false),
|
||||||
error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { row.and_then(|r| r.error.clone()) },
|
error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { interrupted.and_then(|r| r.error.clone()).or_else(|| row.and_then(|r| r.error.clone())) },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> {
|
fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> {
|
||||||
rows.iter().rev().find(|r| r.db == m.db && r.id == m.id)
|
rows.iter().rev().find(|r| {
|
||||||
|
(r.db == m.db || shared_auth(&r.db) && shared_auth(&m.db))
|
||||||
|
&& r.id.eq_ignore_ascii_case(&m.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_history<'a>(rows: &'a [LedgerRow], m: &Migration) -> Vec<&'a LedgerRow> {
|
||||||
|
if shared_auth(&m.db) {
|
||||||
|
rows.iter()
|
||||||
|
.filter(|r| shared_auth(&r.db) && r.id.eq_ignore_ascii_case(&m.id))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
recorded(rows, m).into_iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preserve_auth_history(store: &dyn Store, rows: &[LedgerRow], list: &[Migration]) -> Result<()> {
|
||||||
|
let applied: Vec<_> = list
|
||||||
|
.iter()
|
||||||
|
.filter(|m| shared_auth(&m.db))
|
||||||
|
.filter_map(|m| recorded(rows, m))
|
||||||
|
.filter(|r| r.status == Status::Applied)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
store.put_batch(&applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrations that were published with different bytes but have the same effect, so a server that applied either
|
/// Migrations that were published with different bytes but have the same effect, so a server that applied either
|
||||||
@@ -227,7 +364,7 @@ const SAME_EFFECT: &[(&str, &[&str])] = &[(
|
|||||||
|
|
||||||
fn same_effect(id: &str, a: &str, b: &str) -> bool {
|
fn same_effect(id: &str, a: &str, b: &str) -> bool {
|
||||||
SAME_EFFECT.iter().any(|(known, hashes)| {
|
SAME_EFFECT.iter().any(|(known, hashes)| {
|
||||||
*known == id
|
known.eq_ignore_ascii_case(id)
|
||||||
&& hashes.iter().any(|h| h.eq_ignore_ascii_case(a))
|
&& hashes.iter().any(|h| h.eq_ignore_ascii_case(a))
|
||||||
&& hashes.iter().any(|h| h.eq_ignore_ascii_case(b))
|
&& hashes.iter().any(|h| h.eq_ignore_ascii_case(b))
|
||||||
})
|
})
|
||||||
@@ -251,11 +388,7 @@ pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> {
|
|||||||
|
|
||||||
pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result<usize> {
|
pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result<usize> {
|
||||||
let rows = store.load_readonly()?;
|
let rows = store.load_readonly()?;
|
||||||
check_history(&rows, list)?;
|
Ok(pending_migrations(&rows, list)?.len())
|
||||||
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)> {
|
fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> {
|
||||||
@@ -286,26 +419,51 @@ fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::
|
|||||||
|
|
||||||
/// Check every migration artifact, including applied history, before replacing server files.
|
/// Check every migration artifact, including applied history, before replacing server files.
|
||||||
pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> {
|
pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> {
|
||||||
let mut seen = std::collections::BTreeSet::new();
|
validate_list(list)?;
|
||||||
for m in list {
|
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)?;
|
verified_file(m, dir)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> {
|
fn validate_list(list: &[Migration]) -> Result<()> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
for m in list {
|
for m in list {
|
||||||
if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) {
|
if !ident_ok(&m.id) {
|
||||||
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)));
|
return Err(Error::InvalidManifest(format!(
|
||||||
|
"migration id {:?} is not allowed",
|
||||||
|
m.id
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
if recorded(rows, m).is_some_and(|r| hash_changed(r, m)) {
|
let schema = db::schema_of(&m.db)?;
|
||||||
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)));
|
if !seen.insert((schema, m.id.to_ascii_lowercase())) {
|
||||||
|
return Err(Error::InvalidManifest(format!(
|
||||||
|
"duplicate migration {} ({})",
|
||||||
|
m.id, m.db
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_migrations<'a>(rows: &[LedgerRow], list: &'a [Migration]) -> Result<Vec<&'a Migration>> {
|
||||||
|
check_history(rows, list)?;
|
||||||
|
Ok(list
|
||||||
|
.iter()
|
||||||
|
.filter(|m| recorded(rows, m).is_none_or(|r| r.status != Status::Applied))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> {
|
||||||
|
validate_list(list)?;
|
||||||
|
for m in list {
|
||||||
|
for row in effective_history(rows, m) {
|
||||||
|
if matches!(row.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 hash_changed(row, 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(())
|
Ok(())
|
||||||
@@ -315,12 +473,11 @@ fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> {
|
|||||||
/// was brought up to date by hand).
|
/// was brought up to date by hand).
|
||||||
pub fn baseline(store: &dyn Store, list: &[Migration]) -> Result<usize> {
|
pub fn baseline(store: &dyn Store, list: &[Migration]) -> Result<usize> {
|
||||||
let rows = store.load()?;
|
let rows = store.load()?;
|
||||||
|
check_history(&rows, list)?;
|
||||||
|
preserve_auth_history(store, &rows, list)?;
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
for m in list {
|
for m in list {
|
||||||
if rows
|
if recorded(&rows, m).is_some_and(|r| r.status == Status::Applied) {
|
||||||
.iter()
|
|
||||||
.any(|r| r.db == m.db && r.id == m.id && r.status == Status::Applied)
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
store.put(&LedgerRow {
|
store.put(&LedgerRow {
|
||||||
@@ -353,11 +510,7 @@ pub fn apply_pending(
|
|||||||
) -> Result<ApplyReport> {
|
) -> 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).
|
||||||
check_history(&rows, list)?;
|
let todo = pending_migrations(&rows, list)?;
|
||||||
let todo: Vec<&Migration> = list
|
|
||||||
.iter()
|
|
||||||
.filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied))
|
|
||||||
.collect();
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
total = list.len(),
|
total = list.len(),
|
||||||
pending = todo.len(),
|
pending = todo.len(),
|
||||||
@@ -380,6 +533,7 @@ pub fn apply_pending(
|
|||||||
snapshot: None,
|
snapshot: None,
|
||||||
};
|
};
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
|
preserve_auth_history(store, &rows, list)?;
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
if any_destructive {
|
if any_destructive {
|
||||||
@@ -389,6 +543,7 @@ pub fn apply_pending(
|
|||||||
))
|
))
|
||||||
})?);
|
})?);
|
||||||
}
|
}
|
||||||
|
preserve_auth_history(store, &rows, list)?;
|
||||||
for (m, schema, path) in files {
|
for (m, schema, path) in files {
|
||||||
tracing::info!(migration = %m.id, database = %m.db, "database migration starting");
|
tracing::info!(migration = %m.id, database = %m.db, "database migration starting");
|
||||||
store.put(&LedgerRow {
|
store.put(&LedgerRow {
|
||||||
@@ -572,6 +727,23 @@ mod tests {
|
|||||||
assert!(store.ran.borrow().is_empty());
|
assert!(store.ran.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_entry_point_rejects_duplicate_sql_before_execution() {
|
||||||
|
let (dir, original) = setup(&[("world", "once", "SELECT 1;\n")]);
|
||||||
|
for alias in ["world", "coa-world"] {
|
||||||
|
let mut list = original.clone();
|
||||||
|
let mut duplicate = original[0].clone();
|
||||||
|
duplicate.db = alias.into();
|
||||||
|
list.push(duplicate);
|
||||||
|
let store = Mem::default();
|
||||||
|
assert!(preflight(&store, &list).is_err());
|
||||||
|
assert!(pending_count(&store, &list).is_err());
|
||||||
|
assert!(apply_pending(&store, &list, dir.path(), &no_snapshot).is_err());
|
||||||
|
assert!(store.ran.borrow().is_empty());
|
||||||
|
assert!(store.rows.borrow().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() {
|
fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() {
|
||||||
let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
|
let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
|
||||||
|
|||||||
+193
-15
@@ -130,7 +130,49 @@ fn conf_name(root: &Path, e: &Entry) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn installed_module_commits(root: &Path) -> BTreeMap<String, String> {
|
||||||
|
let Ok(directory) = crate::registry::metadata_dir_for(root) else {
|
||||||
|
return Default::default();
|
||||||
|
};
|
||||||
|
let Ok((_, meta)) = crate::registry::MetaDir::open(&directory) else {
|
||||||
|
return Default::default();
|
||||||
|
};
|
||||||
|
let Some(version) = meta.core.version else {
|
||||||
|
return Default::default();
|
||||||
|
};
|
||||||
|
let candidates = [
|
||||||
|
directory
|
||||||
|
.join("manifests")
|
||||||
|
.join(format!("update-{version}.json")),
|
||||||
|
directory.join("manifests/base.json"),
|
||||||
|
];
|
||||||
|
for path in candidates {
|
||||||
|
let Ok(manifest) = crate::fsx::read_json::<serde_json::Value>(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if manifest.get("version").and_then(|value| value.as_str()) != Some(&version) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(commits) = manifest
|
||||||
|
.get("compatibility")
|
||||||
|
.and_then(|value| value.get("moduleCommits"))
|
||||||
|
.and_then(|value| value.as_object())
|
||||||
|
{
|
||||||
|
return commits
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(name, commit)| {
|
||||||
|
let commit = commit.as_str()?;
|
||||||
|
(commit.len() == 40 && commit.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||||
|
.then(|| (name.clone(), commit.chars().take(8).collect()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Default::default()
|
||||||
|
}
|
||||||
|
|
||||||
fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
|
fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
|
||||||
|
let installed_commits = installed_module_commits(root);
|
||||||
let wildcard = crate::realms::state(root)
|
let wildcard = crate::realms::state(root)
|
||||||
.map(|s| s.active == crate::realms::Mode::Wildcard)
|
.map(|s| s.active == crate::realms::Mode::Wildcard)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
@@ -172,6 +214,18 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let key = match e.id.as_str() {
|
||||||
|
"companions" => "bots",
|
||||||
|
"playerbots" => "squid",
|
||||||
|
"content-scaling" => "scaling",
|
||||||
|
"custom-races" => "races",
|
||||||
|
id => id,
|
||||||
|
};
|
||||||
|
let version = if installed {
|
||||||
|
version.or_else(|| installed_commits.get(key).cloned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
ModuleView {
|
ModuleView {
|
||||||
version,
|
version,
|
||||||
compatibility: if blocked {
|
compatibility: if blocked {
|
||||||
@@ -277,6 +331,17 @@ pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !on {
|
||||||
|
let _ = crate::custom_races::sync_bot_races_config(root, false);
|
||||||
|
} else {
|
||||||
|
let allow =
|
||||||
|
crate::custom_races::is_bot_custom_races_allowed(root).unwrap_or_else(|| {
|
||||||
|
read(&dir(root).join("coa_custom_races.conf"))
|
||||||
|
.and_then(|c| c.get("CoACustomRaces.BotsEnable").map(truthy))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
let _ = crate::custom_races::sync_bot_races_config(root, allow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
set_enabled_in(&catalog(), root, meta, id, on)
|
set_enabled_in(&catalog(), root, meta, id, on)
|
||||||
}
|
}
|
||||||
@@ -383,27 +448,40 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result<Vec<Setting>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let bot_custom_races_allowed = if id == "custom-races" {
|
||||||
|
crate::custom_races::is_bot_custom_races_allowed(root)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
Ok(effective
|
Ok(effective
|
||||||
.entries()
|
.entries()
|
||||||
.map(|(k, v)| Setting {
|
.map(|(k, v)| {
|
||||||
key: k.to_string(),
|
let mut val = fields
|
||||||
value: fields
|
|
||||||
.get(k)
|
.get(k)
|
||||||
.and_then(|field| field.default_if_missing.as_ref())
|
.and_then(|field| field.default_if_missing.as_ref())
|
||||||
.filter(|_| active.get(k).is_none())
|
.filter(|_| active.get(k).is_none())
|
||||||
.and_then(crate::squid::scalar)
|
.and_then(crate::squid::scalar)
|
||||||
.unwrap_or_else(|| v.to_string()),
|
.unwrap_or_else(|| v.to_string());
|
||||||
default: fields
|
if k == "CoACustomRaces.BotsEnable" {
|
||||||
.get(k)
|
if let Some(allowed) = bot_custom_races_allowed {
|
||||||
.and_then(|field| crate::squid::scalar(&field.default))
|
val = if allowed { "1".into() } else { "0".into() };
|
||||||
.or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)),
|
}
|
||||||
field: fields.get(k).cloned(),
|
}
|
||||||
doc: dist
|
Setting {
|
||||||
.as_ref()
|
key: k.to_string(),
|
||||||
.map(|d| d.doc_for(k))
|
value: val,
|
||||||
.filter(|d| !d.is_empty())
|
default: fields
|
||||||
.unwrap_or_else(|| active.doc_for(k))
|
.get(k)
|
||||||
.join(" "),
|
.and_then(|field| crate::squid::scalar(&field.default))
|
||||||
|
.or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)),
|
||||||
|
field: fields.get(k).cloned(),
|
||||||
|
doc: dist
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.doc_for(k))
|
||||||
|
.filter(|d| !d.is_empty())
|
||||||
|
.unwrap_or_else(|| active.doc_for(k))
|
||||||
|
.join(" "),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -508,6 +586,12 @@ fn save_settings_in(
|
|||||||
changed.push((key.clone(), value.to_string()));
|
changed.push((key.clone(), value.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if id == "custom-races" {
|
||||||
|
if let Some(bots_enable) = changes.get("CoACustomRaces.BotsEnable") {
|
||||||
|
let allow = truthy(bots_enable);
|
||||||
|
crate::custom_races::sync_bot_races_config(root, allow)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
if changed.is_empty() {
|
if changed.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -567,6 +651,30 @@ mod tests {
|
|||||||
(root, meta)
|
(root, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn module_revisions_come_from_the_installed_release_only() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("server");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let mut meta =
|
||||||
|
crate::registry::InstallMeta::new(crate::registry::InstallKind::Imported, &root);
|
||||||
|
meta.core.version = Some("2.0.0".into());
|
||||||
|
let directory = crate::registry::MetaDir::create(&root, &meta).unwrap();
|
||||||
|
let value = serde_json::json!({"version": "2.0.0", "compatibility": {"moduleCommits": {"races": "a".repeat(40), "bots": "b".repeat(40)}}});
|
||||||
|
fsx::atomic_write_json(&directory.root.join("manifests/update-2.0.0.json"), &value)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
installed_module_commits(&root)
|
||||||
|
.get("races")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("aaaaaaaa")
|
||||||
|
);
|
||||||
|
let stale = serde_json::json!({"version": "1.0.0", "compatibility": {"moduleCommits": {"races": "c".repeat(40)}}});
|
||||||
|
fsx::atomic_write_json(&directory.root.join("manifests/update-2.0.0.json"), &stale)
|
||||||
|
.unwrap();
|
||||||
|
assert!(installed_module_commits(&root).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn squid_missing_keys_use_code_defaults_and_saves_enforce_json_ranges() {
|
fn squid_missing_keys_use_code_defaults_and_saves_enforce_json_ranges() {
|
||||||
let d = tempfile::tempdir().unwrap();
|
let d = tempfile::tempdir().unwrap();
|
||||||
@@ -1148,4 +1256,74 @@ mod tests {
|
|||||||
.enabled
|
.enabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn custom_races_bot_settings_synchronize_playerbots_conf() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let (root, meta) = server(d.path());
|
||||||
|
let cat = catalog();
|
||||||
|
fs::write(
|
||||||
|
dir(&root).join("coa_custom_races.conf.dist"),
|
||||||
|
"CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir(&root).join("coa_custom_races.conf"),
|
||||||
|
"CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir(&root).join("playerbots.conf"),
|
||||||
|
"AiPlayerbot.Enabled = 1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Initially CoACustomRaces.BotsEnable is 0
|
||||||
|
let s = settings_in(&cat, &root, "custom-races").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.iter()
|
||||||
|
.find(|s| s.key == "CoACustomRaces.BotsEnable")
|
||||||
|
.unwrap()
|
||||||
|
.value,
|
||||||
|
"0"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Enable bots for custom races: should write experimental exclusions to playerbots.conf
|
||||||
|
let change_on = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "1".into())]);
|
||||||
|
assert_eq!(
|
||||||
|
save_settings_in(&cat, &root, &meta, "custom-races", &change_on).unwrap(),
|
||||||
|
["CoACustomRaces.BotsEnable"]
|
||||||
|
);
|
||||||
|
let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap();
|
||||||
|
assert!(pb.contains("AiPlayerbot.ExcludedBotRaces"));
|
||||||
|
assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_EXPERIMENTAL));
|
||||||
|
|
||||||
|
// Settings view now sees "1"
|
||||||
|
let s = settings_in(&cat, &root, "custom-races").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.iter()
|
||||||
|
.find(|s| s.key == "CoACustomRaces.BotsEnable")
|
||||||
|
.unwrap()
|
||||||
|
.value,
|
||||||
|
"1"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Disable bots for custom races: should write recommended exclusions to playerbots.conf
|
||||||
|
let change_off = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "0".into())]);
|
||||||
|
assert_eq!(
|
||||||
|
save_settings_in(&cat, &root, &meta, "custom-races", &change_off).unwrap(),
|
||||||
|
["CoACustomRaces.BotsEnable"]
|
||||||
|
);
|
||||||
|
let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap();
|
||||||
|
assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_RECOMMENDED));
|
||||||
|
|
||||||
|
// Settings view now sees "0"
|
||||||
|
let s = settings_in(&cat, &root, "custom-races").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.iter()
|
||||||
|
.find(|s| s.key == "CoACustomRaces.BotsEnable")
|
||||||
|
.unwrap()
|
||||||
|
.value,
|
||||||
|
"0"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
use std::{collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}};
|
||||||
|
use std::io::{BufReader, BufWriter, Write};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::{fsx, Error, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct Snapshot {
|
||||||
|
pub path: String,
|
||||||
|
pub files: BTreeMap<String, String>,
|
||||||
|
pub directories: BTreeSet<String>,
|
||||||
|
pub server_sha256: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bytes: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archive_sha256: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expanded_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_link(metadata: &fs::Metadata) -> Result<()> {
|
||||||
|
let linked = metadata.file_type().is_symlink();
|
||||||
|
#[cfg(windows)] let linked = { use std::os::windows::fs::MetadataExt; linked || metadata.file_attributes() & 0x400 != 0 };
|
||||||
|
if linked { return Err(Error::Invalid("Recovery copies cannot follow symbolic links or junctions.".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directories(root: &Path) -> Result<BTreeSet<String>> {
|
||||||
|
let canonical = fsx::canonicalize_lenient(root)?;
|
||||||
|
let root = canonical.as_path();
|
||||||
|
let mut result = BTreeSet::new();
|
||||||
|
let mut pending = vec![root.to_path_buf()];
|
||||||
|
while let Some(directory) = pending.pop() {
|
||||||
|
for entry in fs::read_dir(directory)? {
|
||||||
|
let entry = entry?;
|
||||||
|
reject_link(&fs::symlink_metadata(entry.path())?)?;
|
||||||
|
let path = fsx::ensure_within(root, &entry.path())?;
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
let relative = path.strip_prefix(root).map_err(|e| Error::Invalid(e.to_string()))?.to_string_lossy().replace('\\', "/");
|
||||||
|
result.insert(relative);
|
||||||
|
pending.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_snapshot(point: &Path, snapshot: &Snapshot, target: &Path) -> Result<()> {
|
||||||
|
if snapshot.archive_sha256.is_some() {
|
||||||
|
verify(point, snapshot)?;
|
||||||
|
if target.exists() { return Err(Error::Invalid("The recovery destination already exists.".into())); }
|
||||||
|
let parent = target.parent().ok_or_else(|| Error::Invalid("Invalid recovery destination.".into()))?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
fsx::require_space(parent, snapshot.expanded_bytes)?;
|
||||||
|
fs::create_dir(target)?;
|
||||||
|
let input = fsx::ensure_within(point, &fsx::safe_join(point, &snapshot.path)?)?;
|
||||||
|
let decoder = zstd::Decoder::new(BufReader::new(fs::File::open(input)?))?;
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut expanded = 0u64;
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
let name = entry.path()?.to_string_lossy().trim_end_matches('/').to_string();
|
||||||
|
if !seen.insert(name.clone()) { return Err(Error::Invalid("Duplicate entry in MySQL recovery archive.".into())); }
|
||||||
|
let output = fsx::ensure_within(target, &fsx::safe_join(target, &name)?)?;
|
||||||
|
if entry.header().entry_type().is_dir() && snapshot.directories.contains(&name) {
|
||||||
|
fs::create_dir_all(output)?;
|
||||||
|
} else if entry.header().entry_type().is_file() && snapshot.files.contains_key(&name) {
|
||||||
|
expanded = expanded.checked_add(entry.size()).filter(|bytes| *bytes <= snapshot.expanded_bytes)
|
||||||
|
.ok_or_else(|| Error::Invalid("MySQL recovery archive exceeds its declared size.".into()))?;
|
||||||
|
fs::create_dir_all(output.parent().unwrap())?;
|
||||||
|
let mut file = fs::OpenOptions::new().write(true).create_new(true).open(output)?;
|
||||||
|
if std::io::copy(&mut entry, &mut file)? != entry.size() { return Err(Error::Invalid("Truncated MySQL recovery file.".into())); }
|
||||||
|
file.flush()?;
|
||||||
|
} else { return Err(Error::Invalid("Unexpected entry in MySQL recovery archive.".into())); }
|
||||||
|
}
|
||||||
|
if expanded != snapshot.expanded_bytes || inventory(target)? != snapshot.files {
|
||||||
|
return Err(Error::Invalid("MySQL recovery files failed verification.".into()));
|
||||||
|
}
|
||||||
|
} else { copy_verified(&point.join(&snapshot.path), target, &snapshot.files)?; }
|
||||||
|
for directory in &snapshot.directories {
|
||||||
|
fs::create_dir_all(fsx::ensure_within(target, &fsx::safe_join(target, directory)?)?)?;
|
||||||
|
}
|
||||||
|
if directories(target)? != snapshot.directories { return Err(Error::Invalid("The recovery directory inventory differs.".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress(source: &Path, point: &Path, snapshot: &mut Snapshot) -> Result<()> {
|
||||||
|
snapshot.path = "mysql-data.tar.zst".into();
|
||||||
|
let path = fsx::safe_join(point, &snapshot.path)?;
|
||||||
|
fs::create_dir_all(point)?;
|
||||||
|
fsx::require_space(point, snapshot.expanded_bytes)?;
|
||||||
|
let file = fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||||
|
let encoder = zstd::Encoder::new(BufWriter::new(file), 3)?;
|
||||||
|
let mut archive = tar::Builder::new(encoder);
|
||||||
|
for directory in &snapshot.directories {
|
||||||
|
archive.append_dir(directory, fsx::ensure_within(source, &fsx::safe_join(source, directory)?)?)?;
|
||||||
|
}
|
||||||
|
for name in snapshot.files.keys() {
|
||||||
|
let input = fsx::ensure_within(source, &fsx::safe_join(source, name)?)?;
|
||||||
|
archive.append_file(name, &mut fs::File::open(input)?)?;
|
||||||
|
}
|
||||||
|
archive.into_inner()?.finish()?.flush()?;
|
||||||
|
snapshot.archive_sha256 = Some(fsx::sha256_file(&path)?);
|
||||||
|
snapshot.bytes = fs::metadata(path)?.len();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
|
||||||
|
let canonical = fsx::canonicalize_lenient(root)?;
|
||||||
|
let root = canonical.as_path();
|
||||||
|
let mut files = BTreeMap::new();
|
||||||
|
let mut directories = vec![root.to_path_buf()];
|
||||||
|
while let Some(directory) = directories.pop() {
|
||||||
|
for entry in fs::read_dir(directory)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let metadata = fs::symlink_metadata(entry.path())?;
|
||||||
|
let path = fsx::ensure_within(root, &entry.path())?;
|
||||||
|
reject_link(&metadata)?;
|
||||||
|
if metadata.is_dir() { directories.push(path); continue; }
|
||||||
|
if !metadata.is_file() { return Err(Error::Invalid("Unsupported database snapshot file.".into())); }
|
||||||
|
let relative = path.strip_prefix(root).map_err(|e| Error::Invalid(e.to_string()))?.to_string_lossy().replace('\\', "/");
|
||||||
|
fsx::safe_join(root, &relative)?;
|
||||||
|
if relative.ends_with(".isl") { return Err(Error::Invalid("External MySQL tablespaces require a separate recovery method.".into())); }
|
||||||
|
files.insert(relative, fsx::sha256_file(&path)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn copy_verified(source: &Path, target: &Path, files: &BTreeMap<String, String>) -> Result<()> {
|
||||||
|
if target.exists() { return Err(Error::Invalid("Database snapshot destination already exists.".into())); }
|
||||||
|
let bytes = files.keys().try_fold(0u64, |total, name| -> Result<u64> {
|
||||||
|
let path = fsx::ensure_within(source, &fsx::safe_join(source, name)?)?;
|
||||||
|
total.checked_add(fs::metadata(path)?.len()).ok_or_else(|| Error::Invalid("Snapshot size overflow.".into()))
|
||||||
|
})?;
|
||||||
|
let parent = target.parent().ok_or_else(|| Error::Invalid("Invalid snapshot destination.".into()))?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
fsx::require_space(parent, bytes)?;
|
||||||
|
fs::create_dir(target)?;
|
||||||
|
for (relative, expected) in files {
|
||||||
|
let source_file = fsx::ensure_within(source, &fsx::safe_join(source, relative)?)?;
|
||||||
|
let target_file = fsx::ensure_within(target, &fsx::safe_join(target, relative)?)?;
|
||||||
|
fs::create_dir_all(target_file.parent().unwrap())?;
|
||||||
|
fs::copy(&source_file, &target_file)?;
|
||||||
|
if fsx::sha256_file(&target_file)? != *expected { return Err(Error::Invalid(format!("Database snapshot file {relative} failed verification."))); }
|
||||||
|
}
|
||||||
|
if inventory(target)? != *files { return Err(Error::Invalid("Database snapshot inventory differs.".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stop(root: &Path) -> Result<()> {
|
||||||
|
let observed = crate::process::observe(root, &crate::layout::read_ports(root));
|
||||||
|
if [observed.mysql.state, observed.auth.state, observed.world.state].iter().all(|state| *state == crate::process::ServiceState::Stopped)
|
||||||
|
&& !crate::multiworld::is_running(root) { return Ok(()); }
|
||||||
|
let outcome = crate::driver::run(root, crate::driver::Verb::StopAll)
|
||||||
|
.map_err(|error| Error::Invalid(format!("Could not stop server {} before copying or restoring MySQL: {error}", root.display())))?;
|
||||||
|
let status = crate::process::observe(root, &crate::layout::read_ports(root));
|
||||||
|
if !outcome.ok || [status.mysql.state, status.auth.state, status.world.state].iter().any(|state| *state != crate::process::ServiceState::Stopped)
|
||||||
|
|| crate::multiworld::is_running(root) {
|
||||||
|
return Err(Error::Invalid(stop_failure(root, &status, &outcome)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_failure(root: &Path, status: &crate::process::Observed, outcome: &crate::driver::DriverOutcome) -> String {
|
||||||
|
let mut details = vec![format!("The complete server must be stopped before copying or restoring MySQL. Server: {}.", root.display())];
|
||||||
|
for service in [&status.mysql, &status.auth, &status.world].into_iter().chain(status.secondary_world.iter()) {
|
||||||
|
if service.state != crate::process::ServiceState::Stopped {
|
||||||
|
details.push(format!("{}: {:?}; PID: {}; port: {}.", service.name, service.state, service.pid.map(|pid| pid.to_string()).unwrap_or_else(|| "unknown".into()), service.port));
|
||||||
|
}
|
||||||
|
if let Some(conflict) = &service.conflict {
|
||||||
|
details.push(format!("Port {} is used by process {} ({}).", conflict.port, conflict.pid, conflict.exe.as_deref().unwrap_or("executable unavailable")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output = crate::diag::redact(&outcome.output);
|
||||||
|
let lines: Vec<_> = output.lines().rev().take(12).collect();
|
||||||
|
if !lines.is_empty() { details.push(lines.into_iter().rev().collect::<Vec<_>>().join("\n")); }
|
||||||
|
else if !outcome.ok { details.push(format!("The stop command failed (exit code: {:?}).", outcome.exit_code)); }
|
||||||
|
details.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stop_previous_rehearsals(meta: &Path) -> Result<()> {
|
||||||
|
let location = meta.join("rehearsals");
|
||||||
|
if !location.exists() { return Ok(()); }
|
||||||
|
for entry in fs::read_dir(&location)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if name.len() != 32 || !name.bytes().all(|byte| byte.is_ascii_hexdigit()) { continue; }
|
||||||
|
let fixture = fsx::ensure_within(&location, &entry.path().join("server"))?;
|
||||||
|
let metadata = crate::registry::metadata_dir_for(&fixture)?;
|
||||||
|
if !metadata.join("install.json").is_file() { continue; }
|
||||||
|
let (_, installed) = crate::registry::MetaDir::open(&metadata)?;
|
||||||
|
if fsx::canonicalize_lenient(Path::new(&installed.server_path))? != fsx::canonicalize_lenient(&fixture)? {
|
||||||
|
return Err(Error::Invalid("The interrupted rehearsal metadata does not match its private server.".into()));
|
||||||
|
}
|
||||||
|
stop(&fixture)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn capture(root: &Path, partial: &Path) -> Result<Snapshot> {
|
||||||
|
if crate::docker::is_docker(root) || !cfg!(windows) { return Err(Error::Invalid("Cold MySQL snapshots require a Windows repack.".into())); }
|
||||||
|
crate::backup::with_database(root, |db| {
|
||||||
|
if db.query("SELECT COUNT(*) FROM performance_schema.replication_connection_configuration;")?.trim() != "0" {
|
||||||
|
return Err(Error::Invalid("A replicated MySQL instance requires a separate isolated recovery method.".into()));
|
||||||
|
}
|
||||||
|
let data = fsx::ensure_within(root, &root.join("mysql/data"))?;
|
||||||
|
for path in db.query("SELECT PATH FROM information_schema.INNODB_DATAFILES;")?.lines() {
|
||||||
|
fsx::ensure_within(&data, &data.join(path))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
stop(root)?;
|
||||||
|
let data = fsx::ensure_within(root, &root.join("mysql/data"))?;
|
||||||
|
if !data.join("mysql").is_dir() || !data.join("ibdata1").is_file() {
|
||||||
|
return Err(Error::Invalid("The bundled MySQL data directory is incomplete.".into()));
|
||||||
|
}
|
||||||
|
let files = inventory(&data)?;
|
||||||
|
let bytes = files.keys().try_fold(0u64, |total, file| -> Result<u64> {
|
||||||
|
total.checked_add(fs::metadata(fsx::safe_join(&data, file)?)?.len()).ok_or_else(|| Error::Invalid("Snapshot size overflow.".into()))
|
||||||
|
})?;
|
||||||
|
let mut snapshot = Snapshot { path: "mysql-data".into(), files, directories:directories(&data)?, server_sha256: fsx::sha256_file(&root.join("mysql/bin/mysqld.exe"))?, bytes, expanded_bytes:bytes, archive_sha256:None };
|
||||||
|
compress(&data, partial, &mut snapshot)?;
|
||||||
|
verify(partial, &snapshot)?;
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify(point: &Path, snapshot: &Snapshot) -> Result<()> {
|
||||||
|
if snapshot.files.is_empty() || snapshot.server_sha256.len() != 64 {
|
||||||
|
return Err(Error::Invalid("Invalid cold MySQL recovery inventory.".into()));
|
||||||
|
}
|
||||||
|
let data = fsx::ensure_within(point, &fsx::safe_join(point, &snapshot.path)?)?;
|
||||||
|
if let Some(hash) = &snapshot.archive_sha256 {
|
||||||
|
if snapshot.path != "mysql-data.tar.zst" || snapshot.expanded_bytes == 0 || fsx::sha256_file(&data)? != *hash {
|
||||||
|
return Err(Error::Invalid("The compressed MySQL recovery copy is damaged.".into()));
|
||||||
|
}
|
||||||
|
for name in snapshot.files.keys().chain(snapshot.directories.iter()) { fsx::safe_join(point, name)?; }
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if snapshot.path != "mysql-data" { return Err(Error::Invalid("Unsupported MySQL recovery format.".into())); }
|
||||||
|
if inventory(&data)? != snapshot.files { return Err(Error::Invalid("The cold MySQL recovery copy is damaged or incomplete.".into())); }
|
||||||
|
if directories(&data)? != snapshot.directories { return Err(Error::Invalid("The cold MySQL recovery directories are incomplete.".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the replaced data directory. If interrupted between renames, a retry can install a new verified copy.
|
||||||
|
pub(crate) fn swap(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot, fail_after_move: bool) -> Result<PathBuf> {
|
||||||
|
verify(point, snapshot)?;
|
||||||
|
if fsx::sha256_file(&root.join("mysql/bin/mysqld.exe"))? != snapshot.server_sha256 {
|
||||||
|
return Err(Error::Invalid("The MySQL executable differs from the recovery point.".into()));
|
||||||
|
}
|
||||||
|
let mysql = fsx::ensure_within(root, &root.join("mysql"))?;
|
||||||
|
let token = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let staging = fsx::ensure_within(&mysql, &mysql.join(format!("data-restore-{token}")))?;
|
||||||
|
let previous = fsx::ensure_within(&mysql, &mysql.join(format!("data-before-restore-{token}")))?;
|
||||||
|
let live = fsx::ensure_within(&mysql, &mysql.join("data"))?;
|
||||||
|
copy_snapshot(point, snapshot, &staging)?;
|
||||||
|
let journal = meta.join("diagnostics").join(format!("database-restore-{token}.json"));
|
||||||
|
fsx::atomic_write_json(&journal, &serde_json::json!({"schema":1,"state":"prepared","previous":previous,"staging":staging}))?;
|
||||||
|
if live.exists() { fs::rename(&live, &previous)?; }
|
||||||
|
if fail_after_move { return Err(Error::Invalid("Simulated interruption after saving the previous MySQL directory.".into())); }
|
||||||
|
fs::rename(&staging, &live)?;
|
||||||
|
fsx::atomic_write_json(&journal, &serde_json::json!({"schema":1,"state":"restored","previous":previous}))?;
|
||||||
|
Ok(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn failed_stop_identifies_the_server_process_and_retains_redacted_launcher_reason() {
|
||||||
|
use crate::process::{Observed, ServiceStatus, ServiceState};
|
||||||
|
let stopped = |name| ServiceStatus { name, state: ServiceState::Stopped, pid: None, port: 0, port_ready: false, conflict: None, uptime_secs: None };
|
||||||
|
let status = Observed { mysql: ServiceStatus { state: ServiceState::Stopping, pid: Some(42), port: 3307, ..stopped("mysql") }, auth: stopped("auth"), world: stopped("world"), secondary_world: None };
|
||||||
|
let outcome = crate::driver::DriverOutcome { ok: false, exit_code: Some(1), code: None, human: None, output: "MySQL is still stopping; wait before copying the repack.\nrootPassword=hunter2".into() };
|
||||||
|
let message = stop_failure(Path::new("private-rehearsal/server"), &status, &outcome);
|
||||||
|
assert!(message.contains("private-rehearsal") && message.contains("PID: 42") && message.contains("3307"));
|
||||||
|
assert!(message.contains("MySQL is still stopping"));
|
||||||
|
assert!(!message.contains("hunter2"));
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires COA_MYSQL_TEST_BIN pointing to a repack with Runtime/python and Scripts/manage.py"]
|
||||||
|
fn real_shutdown_with_broken_templates_preserves_database_rows() {
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
struct Server(Child);
|
||||||
|
impl Drop for Server { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } }
|
||||||
|
let bin = PathBuf::from(std::env::var("COA_MYSQL_TEST_BIN").unwrap());
|
||||||
|
let source = bin.parent().unwrap().parent().unwrap();
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("shutdown-fixture");
|
||||||
|
copy_verified(&bin, &root.join("mysql/bin"), &inventory(&bin).unwrap()).unwrap();
|
||||||
|
copy_verified(&source.join("Runtime/python"), &root.join("Runtime/python"), &inventory(&source.join("Runtime/python")).unwrap()).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Scripts/manage.py"), &fs::read(source.join("Scripts/manage.py")).unwrap()).unwrap();
|
||||||
|
let data = root.join("mysql/data");
|
||||||
|
let executable = root.join("mysql/bin/mysqld.exe");
|
||||||
|
let initialized = Command::new(&executable).args(["--no-defaults", "--initialize-insecure"])
|
||||||
|
.arg(format!("--datadir={}", data.display())).creation_flags(0x0800_0000).output().unwrap();
|
||||||
|
assert!(initialized.status.success(), "{}", String::from_utf8_lossy(&initialized.stderr));
|
||||||
|
let sockets: Vec<_> = (0..4).map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap()).collect();
|
||||||
|
let ports: Vec<_> = sockets.iter().map(|socket| socket.local_addr().unwrap().port()).collect();
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/repack.json"), &serde_json::json!({"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]})).unwrap();
|
||||||
|
drop(sockets);
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":"","appPassword":""})).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!("[client]\nuser=root\nhost=127.0.0.1\nport={}\nprotocol=tcp\n", ports[0]).as_bytes()).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"WorldDatabaseInfo = @UNKNOWN_DATABASE@\n").unwrap();
|
||||||
|
let original = fs::read(root.join("Settings/worldserver.conf.template")).unwrap();
|
||||||
|
let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap();
|
||||||
|
let start = || {
|
||||||
|
let server = Server(Command::new(&executable).args(["--no-defaults", "--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin"])
|
||||||
|
.arg(format!("--datadir={}", data.display())).arg(format!("--port={}", ports[0]))
|
||||||
|
.arg(format!("--log-error={}", root.join("mysql-test.log").display()))
|
||||||
|
.stdout(Stdio::null()).stderr(Stdio::null()).creation_flags(0x0800_0000).spawn().unwrap());
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||||
|
while !db.ping() { assert!(std::time::Instant::now() < deadline); std::thread::sleep(std::time::Duration::from_millis(200)); }
|
||||||
|
fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(server.0.id()).unwrap()).unwrap();
|
||||||
|
server
|
||||||
|
};
|
||||||
|
let mut server = start();
|
||||||
|
db.query("CREATE DATABASE acore_characters; CREATE TABLE acore_characters.shutdown_probe(id INT PRIMARY KEY); INSERT INTO acore_characters.shutdown_probe VALUES(73);").unwrap();
|
||||||
|
db.query("CREATE DATABASE acore_auth; CREATE DATABASE acore_world; CREATE VIEW acore_characters.shutdown_view AS SELECT id FROM acore_characters.shutdown_probe;").unwrap();
|
||||||
|
let copied_configuration = serde_json::json!({"root":temp.path().join("original-installation"),"ports":{"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]}});
|
||||||
|
fsx::atomic_write_json(&root.join(".state/configuration.json"), &copied_configuration).unwrap();
|
||||||
|
let meta = crate::registry::metadata_dir_for(&root).unwrap();
|
||||||
|
crate::registry::MetaDir::create(&root, &crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root)).unwrap();
|
||||||
|
let point = crate::backup::create(&root, &meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeRepair, None, &|_| {}).unwrap();
|
||||||
|
assert!(point.mysql_snapshot.is_some());
|
||||||
|
assert!(crate::backup::verify(&meta, &point.id).unwrap().ok);
|
||||||
|
crate::update::RepackEnv { root: &root, meta_dir: &meta }.verify_recovery_point(&point).unwrap();
|
||||||
|
assert!(server.0.wait().unwrap().success());
|
||||||
|
assert_eq!(fs::read(root.join("Settings/worldserver.conf.template")).unwrap(), original);
|
||||||
|
assert_eq!(fsx::read_json::<serde_json::Value>(&root.join(".state/configuration.json")).unwrap(), copied_configuration);
|
||||||
|
fs::remove_file(root.join(".state/configuration.json")).unwrap();
|
||||||
|
let mut restarted = start();
|
||||||
|
assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73");
|
||||||
|
fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(std::process::id()).unwrap()).unwrap();
|
||||||
|
assert!(stop(&root).unwrap_err().to_string().contains("different live process"));
|
||||||
|
assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73");
|
||||||
|
fs::remove_file(root.join("Settings/worldserver.conf.template")).unwrap();
|
||||||
|
fs::remove_file(root.join(".state/mysql.json")).unwrap();
|
||||||
|
let observed = crate::process::observe(&root, &crate::layout::read_ports(&root));
|
||||||
|
assert_eq!(observed.mysql.state, crate::process::ServiceState::Running);
|
||||||
|
assert!(observed.mysql.port_ready);
|
||||||
|
assert!(crate::process::listeners().contains(&(ports[0], observed.mysql.pid.unwrap())));
|
||||||
|
stop(&root).unwrap();
|
||||||
|
assert!(restarted.0.wait().unwrap().success());
|
||||||
|
let mut third = start();
|
||||||
|
assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73");
|
||||||
|
db.query("UPDATE acore_characters.shutdown_probe SET id=74;").unwrap();
|
||||||
|
fsx::atomic_write(&root.join(".state/mysql.json"), b"{truncated").unwrap();
|
||||||
|
stop(&root).unwrap();
|
||||||
|
assert!(third.0.wait().unwrap().success());
|
||||||
|
assert_eq!(fs::read(root.join(".state/mysql-before-shutdown-recovery.json")).unwrap(), b"{truncated");
|
||||||
|
let point_dir = crate::backup::point_json(&meta, &point.id).unwrap().parent().unwrap().to_path_buf();
|
||||||
|
let retained = swap(&root, &meta, &point_dir, point.mysql_snapshot.as_ref().unwrap(), false).unwrap();
|
||||||
|
assert!(retained.is_dir());
|
||||||
|
let mut recovered = start();
|
||||||
|
assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73");
|
||||||
|
stop(&root).unwrap();
|
||||||
|
assert!(recovered.0.wait().unwrap().success());
|
||||||
|
// Exercise the public Repair pipeline with real signed packages and SQL,
|
||||||
|
// rather than only the snapshot and journal helpers.
|
||||||
|
let mut package_server = start();
|
||||||
|
db.query("CREATE TABLE acore_auth.account(id INT PRIMARY KEY, username VARCHAR(40)); INSERT INTO acore_auth.account VALUES(73,'FIXTURE'); CREATE TABLE acore_auth.realmlist(id INT PRIMARY KEY, name VARCHAR(40), address VARCHAR(40), localAddress VARCHAR(40), port INT, flag INT); INSERT INTO acore_auth.realmlist VALUES(1,'AzerothCore','127.0.0.1','127.0.0.1',18085,0); CREATE TABLE acore_characters.characters(guid INT PRIMARY KEY, account INT, name VARCHAR(40), race INT, class INT); INSERT INTO acore_characters.characters VALUES(73,73,'Fixture',7,14); CREATE TABLE acore_world.playercreateinfo(race INT, class INT, PRIMARY KEY(race,class));").unwrap();
|
||||||
|
let starts = [1,2,3,4,5,6,7,8,10,11].into_iter().flat_map(|race| (12..=32).map(move |class| format!("({race},{class})"))).collect::<Vec<_>>().join(",");
|
||||||
|
db.query(&format!("INSERT INTO acore_world.playercreateinfo VALUES {starts};")).unwrap();
|
||||||
|
let password = "a".repeat(48);
|
||||||
|
db.query(&format!("ALTER USER 'root'@'localhost' IDENTIFIED BY '{password}';")).unwrap();
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":password,"appPassword":"b".repeat(48)})).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"WorldDatabaseInfo = @WorldDatabaseInfo@\nCharacterDatabaseInfo = @CharacterDatabaseInfo@\nLoginDatabaseInfo = @LoginDatabaseInfo@\n").unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Settings/authserver.conf.template"), b"LoginDatabaseInfo = @LoginDatabaseInfo@\n").unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Settings/mod_ascension_compat.conf.template"), b"Ascension.DbcPath = @ASCENSION_DBC@\n").unwrap();
|
||||||
|
let startup = crate::driver::run(&root, crate::driver::Verb::StartMysql).unwrap();
|
||||||
|
assert!(startup.ok, "{}", crate::diag::redact(&startup.output));
|
||||||
|
let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap();
|
||||||
|
crate::schema_check::capture(&db, &root).unwrap();
|
||||||
|
assert!(crate::schema_check::check(&db, &root).unwrap().is_empty());
|
||||||
|
stop(&root).unwrap();
|
||||||
|
assert!(package_server.0.wait().unwrap().success());
|
||||||
|
struct StopFixture(PathBuf);
|
||||||
|
impl Drop for StopFixture { fn drop(&mut self) { let _ = stop(&self.0); } }
|
||||||
|
let _stop_fixture = StopFixture(root.clone());
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
let key = SigningKey::from_bytes(&[7;32]);
|
||||||
|
let trusted = STANDARD.encode(key.verifying_key().to_bytes());
|
||||||
|
let source = temp.path().join("repair-package-source");
|
||||||
|
fsx::atomic_write(&source.join("Core/reference/repair-probe.dll"), b"official").unwrap();
|
||||||
|
fsx::atomic_write(&source.join(crate::schema_check::CONTRACT), &fs::read(root.join(crate::schema_check::CONTRACT)).unwrap()).unwrap();
|
||||||
|
let build = |kind, version: &str, destination: &Path, migrations| {
|
||||||
|
let manifest = crate::package::build(&source, destination, &crate::package::BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1024*1024, bots_commit: None, migrations }, &|_| {}).unwrap();
|
||||||
|
let bytes = fs::read(destination.join("manifest.json")).unwrap();
|
||||||
|
fsx::atomic_write(&destination.join("manifest.json.sig"), STANDARD.encode(key.sign(&bytes).to_bytes()).as_bytes()).unwrap();
|
||||||
|
manifest
|
||||||
|
};
|
||||||
|
let base = temp.path().join("repair-base");
|
||||||
|
let base_manifest = build(crate::manifest::Kind::Base, "1.0.0", &base, vec![]);
|
||||||
|
let update = temp.path().join("repair-update");
|
||||||
|
let update_manifest = build(crate::manifest::Kind::Update, "2.0.0", &update, vec![]);
|
||||||
|
fsx::atomic_write_json(&meta.join("manifests/base.json"), &base_manifest).unwrap();
|
||||||
|
fsx::atomic_write_json(&meta.join("manifests/update-2.0.0.json"), &update_manifest).unwrap();
|
||||||
|
let (_, mut installed) = crate::registry::MetaDir::open(&meta).unwrap();
|
||||||
|
installed.core.version = Some("2.0.0".into());
|
||||||
|
fsx::atomic_write_json(&meta.join("install.json"), &installed).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"broken").unwrap();
|
||||||
|
let base_source = crate::pkgsource::Source::Dir(base);
|
||||||
|
let update_source = crate::pkgsource::Source::Dir(update);
|
||||||
|
let report = crate::repair::run(&root, &meta, &base_source, &update_source, &trusted, &|_,_| {}).unwrap();
|
||||||
|
assert!(report.error.is_none());
|
||||||
|
assert!(report.database.iter().all(|check| check.problems.is_empty()));
|
||||||
|
assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"official");
|
||||||
|
crate::update::ensure_recovered(&meta).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"before verification failure").unwrap();
|
||||||
|
let error = crate::repair::run(&root, &meta, &base_source, &update_source, &trusted, &|phase,_| {
|
||||||
|
if phase == "Checking database after repair" { fs::remove_file(root.join("Core/reference/repair-probe.dll")).unwrap(); }
|
||||||
|
}).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("repair"));
|
||||||
|
assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"before verification failure");
|
||||||
|
crate::update::ensure_recovered(&meta).unwrap();
|
||||||
|
let failed_sql = "UPDATE shutdown_probe SET id=75; CREATE TABLE repair_partial(id INT); INVALID SQL;";
|
||||||
|
fsx::atomic_write(&source.join("_migrations/characters/repair-failure.sql"), failed_sql.as_bytes()).unwrap();
|
||||||
|
let failing = temp.path().join("repair-failing-update");
|
||||||
|
let manifest = build(crate::manifest::Kind::Update, "2.0.0", &failing, vec![crate::manifest::Migration { id:"repair-failure".into(), db:"characters".into(), sha256:fsx::sha256_bytes(failed_sql.as_bytes()), compatible_sha256:vec![], destructive:false }]);
|
||||||
|
fsx::atomic_write_json(&meta.join("manifests/update-2.0.0.json"), &manifest).unwrap();
|
||||||
|
fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"before failed repair").unwrap();
|
||||||
|
let report = crate::repair::run(&root, &meta, &base_source, &crate::pkgsource::Source::Dir(failing), &trusted, &|_,_| {}).unwrap();
|
||||||
|
assert!(report.error.is_some());
|
||||||
|
assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"before failed repair");
|
||||||
|
crate::update::ensure_recovered(&meta).unwrap();
|
||||||
|
crate::backup::with_database(&root, |db| {
|
||||||
|
assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;")?, "73");
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='acore_characters' AND TABLE_NAME='repair_partial';")?, "0");
|
||||||
|
assert_eq!(db.query("SELECT username FROM acore_auth.account WHERE id=73;")?, "FIXTURE");
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires COA_MYSQL_TEST_BIN pointing to the bundled MySQL tools"]
|
||||||
|
fn real_mysql_restores_objects_users_rows_and_missing_defaults() {
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
struct Server(Child);
|
||||||
|
impl Drop for Server { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } }
|
||||||
|
let bin = PathBuf::from(std::env::var("COA_MYSQL_TEST_BIN").unwrap());
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("server");
|
||||||
|
let meta = temp.path().join("metadata");
|
||||||
|
let point = temp.path().join("point");
|
||||||
|
copy_verified(&bin, &root.join("mysql/bin"), &inventory(&bin).unwrap()).unwrap();
|
||||||
|
let data = root.join("mysql/data");
|
||||||
|
let executable = root.join("mysql/bin/mysqld.exe");
|
||||||
|
let initialized = Command::new(&executable).args(["--no-defaults", "--initialize-insecure"])
|
||||||
|
.arg(format!("--datadir={}", data.display())).creation_flags(0x0800_0000).output().unwrap();
|
||||||
|
assert!(initialized.status.success(), "{}", String::from_utf8_lossy(&initialized.stderr));
|
||||||
|
let port = crate::install::free_port().unwrap();
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/repack.json"), &serde_json::json!({"mysqlPort":port})).unwrap();
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":"","appPassword":""})).unwrap();
|
||||||
|
let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap();
|
||||||
|
let start = || {
|
||||||
|
let server = Server(Command::new(&executable).args(["--no-defaults", "--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin"])
|
||||||
|
.arg(format!("--datadir={}", data.display())).arg(format!("--port={port}"))
|
||||||
|
.arg(format!("--log-error={}", root.join("mysql-test.log").display()))
|
||||||
|
.stdout(Stdio::null()).stderr(Stdio::null()).creation_flags(0x0800_0000).spawn().unwrap());
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||||
|
while !db.ping() { assert!(std::time::Instant::now() < deadline, "{}", fs::read_to_string(root.join("mysql-test.log")).unwrap_or_default()); std::thread::sleep(std::time::Duration::from_millis(200)); }
|
||||||
|
server
|
||||||
|
};
|
||||||
|
let mut server = start();
|
||||||
|
db.query("CREATE DATABASE acore_characters; CREATE DATABASE acore_world; CREATE DATABASE acore_auth; CREATE TABLE acore_characters.probe(id INT PRIMARY KEY, note VARCHAR(40)); CREATE TABLE acore_world.probe(id INT NOT NULL DEFAULT 0); INSERT INTO acore_world.probe VALUES(43); CREATE TABLE acore_auth.probe(id INT); INSERT INTO acore_auth.probe VALUES(44); CREATE USER 'fixture_user'@'localhost' IDENTIFIED BY 'fixture'; CREATE TRIGGER acore_characters.probe_insert BEFORE INSERT ON acore_characters.probe FOR EACH ROW SET NEW.note='triggered'; INSERT INTO acore_characters.probe VALUES(42,'original'); CREATE VIEW acore_characters.probe_view AS SELECT c.id,w.id AS world_id FROM acore_characters.probe c CROSS JOIN acore_world.probe w; CREATE PROCEDURE acore_characters.probe_proc() SELECT id FROM acore_characters.probe; CREATE FUNCTION acore_characters.probe_func() RETURNS INT DETERMINISTIC RETURN 73;").unwrap();
|
||||||
|
// Shared auth must not run again when the active realm changes.
|
||||||
|
use crate::migrations::Store;
|
||||||
|
db.query("CREATE DATABASE acore_world_wildcard;").unwrap();
|
||||||
|
let migration_dir = root.join("migration-fixture");
|
||||||
|
fs::create_dir_all(migration_dir.join("auth")).unwrap();
|
||||||
|
fs::create_dir_all(migration_dir.join("world")).unwrap();
|
||||||
|
let auth_sql = b"INSERT INTO acore_auth.probe VALUES(45);";
|
||||||
|
fs::write(migration_dir.join("auth/shared_once.sql"), auth_sql).unwrap();
|
||||||
|
let auth_list = vec![crate::manifest::Migration { id:"shared_once".into(), db:"auth".into(), sha256:fsx::sha256_bytes(auth_sql), destructive:false, compatible_sha256:vec![] }];
|
||||||
|
assert_eq!(crate::migrations::apply_pending(&db, &auth_list, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.len(), 1);
|
||||||
|
let wildcard = db.clone().for_realm(crate::realms::Mode::Wildcard);
|
||||||
|
assert_eq!(crate::migrations::pending_count(&wildcard, &auth_list).unwrap(), 0, "auth is shared by both realms");
|
||||||
|
assert!(crate::migrations::apply_pending(&wildcard, &auth_list, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty());
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.probe WHERE id=45;").unwrap(), "1");
|
||||||
|
let mut failed = crate::migrations::LedgerRow { db:"auth".into(), id:"shared_once".into(), sha256:auth_list[0].sha256.clone(), status:crate::migrations::Status::Failed, error:Some("interrupted fixture".into()), baseline:false };
|
||||||
|
db.put(&failed).unwrap();
|
||||||
|
assert!(crate::migrations::pending_count(&wildcard, &auth_list).is_err());
|
||||||
|
failed.status = crate::migrations::Status::Applied; failed.error = None; db.put(&failed).unwrap();
|
||||||
|
|
||||||
|
|
||||||
|
// Legacy per-world auth records are adopted without replaying their SQL.
|
||||||
|
let legacy_sql = b"INSERT INTO acore_auth.probe VALUES(46);";
|
||||||
|
fs::write(migration_dir.join("auth/legacy_auth.sql"), legacy_sql).unwrap();
|
||||||
|
db.query(&format!("INSERT INTO acore_auth.probe VALUES(46); REPLACE INTO acore_world.coa_manager_migrations VALUES('auth','legacy_auth','{}','applied',NOW(),NULL,0);", fsx::sha256_bytes(legacy_sql))).unwrap();
|
||||||
|
let legacy_auth = vec![crate::manifest::Migration { id:"legacy_auth".into(), db:"auth".into(), sha256:fsx::sha256_bytes(legacy_sql), destructive:false, compatible_sha256:vec![] }];
|
||||||
|
assert_eq!(crate::migrations::pending_count(&wildcard, &legacy_auth).unwrap(), 0);
|
||||||
|
assert!(crate::migrations::apply_pending(&wildcard, &legacy_auth, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty());
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.probe WHERE id=46;").unwrap(), "1");
|
||||||
|
|
||||||
|
// A core history cleanup cannot make previously recorded SQL pending again.
|
||||||
|
db.query("CREATE TABLE acore_world.updates(name VARCHAR(150) PRIMARY KEY) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT INTO acore_world.updates VALUES('LEGACY_WORLD.sql'); INSERT INTO acore_world.probe VALUES(99);").unwrap();
|
||||||
|
db.load().unwrap();
|
||||||
|
db.query("DROP TABLE acore_world.updates;").unwrap();
|
||||||
|
let legacy_world_sql = b"INSERT INTO acore_world.probe VALUES(99);";
|
||||||
|
fs::write(migration_dir.join("world/legacy_world.sql"), legacy_world_sql).unwrap();
|
||||||
|
let legacy_world = vec![crate::manifest::Migration { id:"legacy_world".into(), db:"world".into(), sha256:fsx::sha256_bytes(legacy_world_sql), destructive:false, compatible_sha256:vec![] }];
|
||||||
|
assert_eq!(crate::migrations::pending_count(&db, &legacy_world).unwrap(), 0);
|
||||||
|
assert!(crate::migrations::apply_pending(&db, &legacy_world, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty());
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM acore_world.probe WHERE id=99;").unwrap(), "1");
|
||||||
|
db.query("DELETE FROM acore_world.probe WHERE id=99;").unwrap();
|
||||||
|
assert_eq!(db.recovery_objects("acore_characters").unwrap().len(), 4);
|
||||||
|
db.query("CREATE TABLE acore_characters.coa_squid_pending(path VARCHAR(255), sha256 CHAR(64)); INSERT INTO acore_characters.coa_squid_pending VALUES('playerbots/base/ai_playerbot_texts.sql', REPEAT('a',64));").unwrap();
|
||||||
|
let squid_problems = crate::squid::database_problems(&root, crate::realms::Mode::Coa, &|sql| {
|
||||||
|
assert!(sql.starts_with("SELECT "));
|
||||||
|
db.query(sql)
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(squid_problems.len(), 1);
|
||||||
|
assert!(squid_problems[0].detail.contains("interrupted"));
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.coa_squid_pending;").unwrap(), "1");
|
||||||
|
crate::schema_check::capture(&db, &root).unwrap();
|
||||||
|
let hash = fsx::sha256_file(&root.join(crate::schema_check::CONTRACT)).unwrap();
|
||||||
|
db.query("ALTER TABLE acore_world.probe ALTER COLUMN id DROP DEFAULT;").unwrap();
|
||||||
|
crate::schema_check::repair_missing_defaults(&db, &root, Some(&hash)).unwrap();
|
||||||
|
assert_eq!(db.query("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='acore_world' AND TABLE_NAME='probe' AND COLUMN_NAME='id';").unwrap(), "0");
|
||||||
|
assert_eq!(db.query("SELECT id FROM acore_world.probe;").unwrap(), "43");
|
||||||
|
db.query("ALTER TABLE acore_world.probe ALTER COLUMN id SET DEFAULT 99;").unwrap();
|
||||||
|
crate::schema_check::repair_missing_defaults(&db, &root, Some(&hash)).unwrap();
|
||||||
|
assert_eq!(db.query("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='acore_world' AND TABLE_NAME='probe' AND COLUMN_NAME='id';").unwrap(), "99");
|
||||||
|
db.query("SHUTDOWN;").unwrap(); server.0.wait().unwrap();
|
||||||
|
let files = inventory(&data).unwrap();
|
||||||
|
copy_verified(&data, &point.join("mysql-data"), &files).unwrap();
|
||||||
|
let expanded_bytes = files.keys().map(|name| fs::metadata(data.join(name)).unwrap().len()).sum();
|
||||||
|
let mut snapshot = Snapshot {path:"mysql-data".into(), files, directories:directories(&data).unwrap(), server_sha256:fsx::sha256_file(&executable).unwrap(), bytes:0, expanded_bytes, archive_sha256:None};
|
||||||
|
for directory in &snapshot.directories { fs::create_dir_all(point.join("mysql-data").join(directory)).unwrap(); }
|
||||||
|
compress(&data, &point, &mut snapshot).unwrap();
|
||||||
|
let mut changed = start();
|
||||||
|
db.query("DROP VIEW acore_characters.probe_view; DROP TRIGGER acore_characters.probe_insert; DROP PROCEDURE acore_characters.probe_proc; DROP FUNCTION acore_characters.probe_func; DELETE FROM acore_characters.probe; DROP USER 'fixture_user'@'localhost';").unwrap();
|
||||||
|
db.query("SHUTDOWN;").unwrap(); changed.0.wait().unwrap();
|
||||||
|
swap(&root, &meta, &point, &snapshot, false).unwrap();
|
||||||
|
let mut restored = start();
|
||||||
|
assert_eq!(db.recovery_objects("acore_characters").unwrap().len(), 4);
|
||||||
|
assert_eq!(db.query("SELECT * FROM acore_characters.probe_view; CALL acore_characters.probe_proc(); SELECT acore_characters.probe_func(); SELECT COUNT(*) FROM mysql.user WHERE user='fixture_user';").unwrap().replace("\r\n", "\n"), "42\t43\n42\n73\n1");
|
||||||
|
db.query("INSERT INTO acore_characters.probe VALUES(45,'check');").unwrap();
|
||||||
|
assert_eq!(db.query("SELECT note FROM acore_characters.probe WHERE id=45;").unwrap(), "triggered");
|
||||||
|
db.query("SHUTDOWN;").unwrap(); restored.0.wait().unwrap();
|
||||||
|
}
|
||||||
|
fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, Snapshot) {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("server");
|
||||||
|
let meta = temp.path().join("metadata");
|
||||||
|
let point = temp.path().join("point");
|
||||||
|
fsx::atomic_write(&root.join("mysql/bin/mysqld.exe"), b"same-engine").unwrap();
|
||||||
|
fsx::atomic_write(&root.join("mysql/data/characters.ibd"), b"live").unwrap();
|
||||||
|
fsx::atomic_write(&point.join("mysql-data/characters.ibd"), b"saved").unwrap();
|
||||||
|
fs::create_dir_all(point.join("mysql-data/empty_schema")).unwrap();
|
||||||
|
let snapshot = Snapshot { path:"mysql-data".into(), files:inventory(&point.join("mysql-data")).unwrap(), directories:directories(&point.join("mysql-data")).unwrap(), server_sha256:fsx::sha256_file(&root.join("mysql/bin/mysqld.exe")).unwrap(), bytes:5, expanded_bytes:5, archive_sha256:None };
|
||||||
|
(temp, root, meta, point, snapshot)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn interrupted_directory_swap_is_recoverable_and_keeps_previous_bytes() {
|
||||||
|
let (_temp, root, meta, point, snapshot) = fixture();
|
||||||
|
assert!(swap(&root, &meta, &point, &snapshot, true).is_err());
|
||||||
|
assert!(!root.join("mysql/data").exists());
|
||||||
|
let old = fs::read_dir(root.join("mysql")).unwrap().flatten().find(|e| e.file_name().to_string_lossy().starts_with("data-before-")).unwrap().path();
|
||||||
|
assert_eq!(fs::read(old.join("characters.ibd")).unwrap(), b"live");
|
||||||
|
swap(&root, &meta, &point, &snapshot, false).unwrap();
|
||||||
|
assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"saved");
|
||||||
|
assert!(root.join("mysql/data/empty_schema").is_dir());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn corrupt_or_different_engine_snapshots_do_not_move_live_data() {
|
||||||
|
let (_temp, root, meta, point, snapshot) = fixture();
|
||||||
|
fsx::atomic_write(&point.join("mysql-data/characters.ibd"), b"corrupt").unwrap();
|
||||||
|
assert!(swap(&root, &meta, &point, &snapshot, false).is_err());
|
||||||
|
assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live");
|
||||||
|
fsx::atomic_write(&point.join("mysql-data/characters.ibd"), b"saved").unwrap();
|
||||||
|
fsx::atomic_write(&root.join("mysql/bin/mysqld.exe"), b"new-engine").unwrap();
|
||||||
|
assert!(swap(&root, &meta, &point, &snapshot, false).is_err());
|
||||||
|
assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn compressed_snapshots_verify_every_file_and_reject_unlisted_entries() {
|
||||||
|
let (_temp, root, meta, point, mut snapshot) = fixture();
|
||||||
|
let source = point.join("mysql-data");
|
||||||
|
compress(&source, &point, &mut snapshot).unwrap();
|
||||||
|
assert!(snapshot.archive_sha256.is_some());
|
||||||
|
swap(&root, &meta, &point, &snapshot, false).unwrap();
|
||||||
|
assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"saved");
|
||||||
|
fsx::atomic_write(&root.join("mysql/data/characters.ibd"), b"preserve").unwrap();
|
||||||
|
let archive_path = point.join(&snapshot.path);
|
||||||
|
let encoder = zstd::Encoder::new(fs::File::create(&archive_path).unwrap(), 3).unwrap();
|
||||||
|
let mut archive = tar::Builder::new(encoder);
|
||||||
|
archive.append_file("unlisted.ibd", &mut fs::File::open(source.join("characters.ibd")).unwrap()).unwrap();
|
||||||
|
archive.into_inner().unwrap().finish().unwrap();
|
||||||
|
snapshot.archive_sha256 = Some(fsx::sha256_file(&archive_path).unwrap());
|
||||||
|
assert!(swap(&root, &meta, &point, &snapshot, false).is_err());
|
||||||
|
assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"preserve");
|
||||||
|
fsx::atomic_write(&archive_path, b"damaged").unwrap();
|
||||||
|
assert!(verify(&point, &snapshot).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -264,6 +264,7 @@ pub fn build(
|
|||||||
split.close_current()?;
|
split.close_current()?;
|
||||||
|
|
||||||
let manifest = Manifest {
|
let manifest = Manifest {
|
||||||
|
compatibility: None,
|
||||||
schema: crate::manifest::SCHEMA,
|
schema: crate::manifest::SCHEMA,
|
||||||
kind: opts.kind,
|
kind: opts.kind,
|
||||||
version: opts.version.clone(),
|
version: opts.version.clone(),
|
||||||
@@ -723,6 +724,7 @@ mod tests {
|
|||||||
fs::write(d.path().join(name), &bytes).unwrap();
|
fs::write(d.path().join(name), &bytes).unwrap();
|
||||||
let ok_sha = fsx::sha256_bytes(b"data");
|
let ok_sha = fsx::sha256_bytes(b"data");
|
||||||
let m = Manifest {
|
let m = Manifest {
|
||||||
|
compatibility: None,
|
||||||
schema: 1,
|
schema: 1,
|
||||||
kind: Kind::Base,
|
kind: Kind::Base,
|
||||||
version: "0.1.0".into(),
|
version: "0.1.0".into(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::signing;
|
|||||||
/// Where a package comes from.
|
/// Where a package comes from.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Source {
|
pub enum Source {
|
||||||
|
Installation(Box<Source>),
|
||||||
/// `https://.../` base URL holding manifest.json, manifest.json.sig and the parts.
|
/// `https://.../` base URL holding manifest.json, manifest.json.sig and the parts.
|
||||||
Url(String),
|
Url(String),
|
||||||
/// A folder produced by `package::build` plus its `manifest.json.sig` (offline / local installs).
|
/// A folder produced by `package::build` plus its `manifest.json.sig` (offline / local installs).
|
||||||
@@ -23,6 +24,14 @@ pub enum Source {
|
|||||||
/// Read a small file (manifest, signature) from the source.
|
/// Read a small file (manifest, signature) from the source.
|
||||||
pub fn fetch_small(source: &Source, name: &str) -> Result<Vec<u8>> {
|
pub fn fetch_small(source: &Source, name: &str) -> Result<Vec<u8>> {
|
||||||
match source {
|
match source {
|
||||||
|
Source::Installation(source) => fetch_small(
|
||||||
|
source,
|
||||||
|
&if matches!(name, "manifest.json" | "manifest.json.sig") {
|
||||||
|
format!("install-{name}")
|
||||||
|
} else {
|
||||||
|
name.into()
|
||||||
|
},
|
||||||
|
),
|
||||||
Source::Dir(d) => Ok(fs::read(fsx::safe_join(d, name)?)
|
Source::Dir(d) => Ok(fs::read(fsx::safe_join(d, name)?)
|
||||||
.map_err(|_| Error::Invalid(format!("{name} was not found in the package folder.")))?),
|
.map_err(|_| Error::Invalid(format!("{name} was not found in the package folder.")))?),
|
||||||
Source::Url(base) => {
|
Source::Url(base) => {
|
||||||
@@ -56,6 +65,23 @@ pub fn fetch_manifest(source: &Source, trusted_key: &str) -> Result<(Manifest, V
|
|||||||
let sig = String::from_utf8_lossy(&fetch_small(source, "manifest.json.sig")?).into_owned();
|
let sig = String::from_utf8_lossy(&fetch_small(source, "manifest.json.sig")?).into_owned();
|
||||||
signing::verify(&bytes, &sig, trusted_key)?;
|
signing::verify(&bytes, &sig, trusted_key)?;
|
||||||
let manifest = Manifest::parse(&bytes)?;
|
let manifest = Manifest::parse(&bytes)?;
|
||||||
|
if let Source::Installation(release) = source {
|
||||||
|
let (update, _) = fetch_manifest(release, trusted_key)?;
|
||||||
|
if manifest.kind != crate::manifest::Kind::Base
|
||||||
|
|| update.kind != crate::manifest::Kind::Update
|
||||||
|
|| manifest.version != update.version
|
||||||
|
|| manifest.core.commit != update.core.commit
|
||||||
|
|| manifest
|
||||||
|
.bots
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.commit.as_ref())
|
||||||
|
!= update.bots.as_ref().and_then(|value| value.commit.as_ref())
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidManifest(
|
||||||
|
"The full installation package does not match the selected server release.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok((manifest, bytes))
|
Ok((manifest, bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +115,7 @@ pub fn fetch_parts(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?;
|
.ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?;
|
||||||
match source {
|
match source {
|
||||||
|
Source::Installation(source) => fetch_parts(source, manifest, download_dir, cancel, report),
|
||||||
Source::Dir(d) => Ok(d.clone()),
|
Source::Dir(d) => Ok(d.clone()),
|
||||||
Source::Url(base) => {
|
Source::Url(base) => {
|
||||||
let all: u64 = archive.parts.iter().map(|p| p.size).sum::<u64>().max(1);
|
let all: u64 = archive.parts.iter().map(|p| p.size).sum::<u64>().max(1);
|
||||||
@@ -124,6 +151,48 @@ pub fn fetch_parts(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_installation_requires_the_signed_full_image_of_the_selected_release() {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
let folder = tempfile::tempdir().unwrap();
|
||||||
|
let signing_key = SigningKey::from_bytes(&[23; 32]);
|
||||||
|
let key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||||
|
let source = Source::Installation(Box::new(Source::Dir(folder.path().into())));
|
||||||
|
let mut manifest = serde_json::json!({"schema": 1, "kind": "update", "version": "0.261014.1",
|
||||||
|
"core": {"commit": "a".repeat(40)}, "builtAt": "test", "minManagerVersion": "0.6.16",
|
||||||
|
"files": [], "migrations": [], "archive": null});
|
||||||
|
let write = |name: &str, value: &serde_json::Value| {
|
||||||
|
let bytes = serde_json::to_vec(value).unwrap();
|
||||||
|
fs::write(folder.path().join(name), &bytes).unwrap();
|
||||||
|
fs::write(
|
||||||
|
folder.path().join(format!("{name}.sig")),
|
||||||
|
STANDARD.encode(signing_key.sign(&bytes).to_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
write("manifest.json", &manifest);
|
||||||
|
assert!(fetch_manifest(&source, &key).is_err());
|
||||||
|
manifest["kind"] = "base".into();
|
||||||
|
write("install-manifest.json", &manifest);
|
||||||
|
assert_eq!(
|
||||||
|
fetch_manifest(&source, &key).unwrap().0.version,
|
||||||
|
"0.261014.1"
|
||||||
|
);
|
||||||
|
manifest["version"] = "0.2.0".into();
|
||||||
|
write("install-manifest.json", &manifest);
|
||||||
|
assert!(fetch_manifest(&source, &key).is_err());
|
||||||
|
manifest["version"] = "0.261014.1".into();
|
||||||
|
manifest["core"]["commit"] = "b".repeat(40).into();
|
||||||
|
write("install-manifest.json", &manifest);
|
||||||
|
assert!(fetch_manifest(&source, &key).is_err());
|
||||||
|
fs::write(folder.path().join("install-manifest.json"), b"tampered").unwrap();
|
||||||
|
assert!(fetch_manifest(&source, &key)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("signature"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sizes_are_shown_in_binary_units() {
|
fn sizes_are_shown_in_binary_units() {
|
||||||
assert_eq!(human_bytes(0), "0 B");
|
assert_eq!(human_bytes(0), "0 B");
|
||||||
|
|||||||
@@ -96,15 +96,38 @@ pub fn hardware() -> Hardware {
|
|||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn hardware() -> Hardware {
|
pub fn hardware() -> Hardware {
|
||||||
|
let (total, free) = std::fs::read_to_string("/proc/meminfo")
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| parse_meminfo(&t))
|
||||||
|
.unwrap_or((8.0, 4.0));
|
||||||
Hardware {
|
Hardware {
|
||||||
cores: std::thread::available_parallelism()
|
cores: std::thread::available_parallelism()
|
||||||
.map(|n| n.get() as u32)
|
.map(|n| n.get() as u32)
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
ram_gb: 8.0,
|
ram_gb: total,
|
||||||
free_ram_gb: 4.0,
|
free_ram_gb: free,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (total, available) memory in GB from the text of `/proc/meminfo`.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn parse_meminfo(text: &str) -> Option<(f64, f64)> {
|
||||||
|
let kb = |key: &str| -> Option<f64> {
|
||||||
|
text.lines().find_map(|l| {
|
||||||
|
l.strip_prefix(key)?
|
||||||
|
.trim()
|
||||||
|
.strip_suffix("kB")?
|
||||||
|
.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
kb("MemTotal:")? / (1u64 << 20) as f64,
|
||||||
|
kb("MemAvailable:")? / (1u64 << 20) as f64,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SizeOption {
|
pub struct SizeOption {
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
@@ -151,6 +174,19 @@ pub fn sizes(h: &Hardware) -> Vec<SizeOption> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn memory_is_read_from_proc_meminfo() {
|
||||||
|
let t = "MemTotal: 65536000 kB\nMemFree: 1000000 kB\nMemAvailable: 16777216 kB\nBuffers: 1 kB\n";
|
||||||
|
let (total, free) = parse_meminfo(t).unwrap();
|
||||||
|
assert!(
|
||||||
|
(total - 62.5).abs() < 0.01 && (free - 16.0).abs() < 0.01,
|
||||||
|
"{total} {free}"
|
||||||
|
);
|
||||||
|
assert!(parse_meminfo("nothing useful").is_none());
|
||||||
|
assert!(hardware().ram_gb > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_counts_and_never_goes_negative() {
|
fn parses_counts_and_never_goes_negative() {
|
||||||
let p = parse_counts("12\t9\t400").unwrap();
|
let p = parse_counts("12\t9\t400").unwrap();
|
||||||
|
|||||||
@@ -334,22 +334,32 @@ fn service_with(
|
|||||||
port: u16,
|
port: u16,
|
||||||
listen: &[(u16, u32)],
|
listen: &[(u16, u32)],
|
||||||
) -> ServiceStatus {
|
) -> ServiceStatus {
|
||||||
// Prefer the repack's own record when it still matches a live process; otherwise discover by exact exe path.
|
// Prefer the exact installed executable owning the port; otherwise use a valid record or path discovery.
|
||||||
let norm = |p: &Path| p.to_string_lossy().replace('/', "\\");
|
let norm = |p: &Path| p.to_string_lossy().replace('/', "\\");
|
||||||
let expected: Vec<String> = std::iter::once(&exe)
|
let expected: Vec<String> = std::iter::once(&exe)
|
||||||
.chain(alternatives)
|
.chain(alternatives)
|
||||||
.map(|p| norm(p))
|
.map(|p| norm(p))
|
||||||
.collect();
|
.collect();
|
||||||
let proc = read_state_record(root, name)
|
let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid);
|
||||||
.filter(is_alive)
|
// MySQL can launch a supervisor and a server with the same executable. The
|
||||||
.filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e)))
|
// listening process is authoritative when its exact installation path matches.
|
||||||
|
let proc = owner
|
||||||
|
.and_then(identity)
|
||||||
|
.filter(|record| {
|
||||||
|
expected
|
||||||
|
.iter()
|
||||||
|
.any(|exe| record.exe.eq_ignore_ascii_case(exe))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
read_state_record(root, name)
|
||||||
|
.filter(is_alive)
|
||||||
|
.filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e)))
|
||||||
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
std::iter::once(&exe)
|
std::iter::once(&exe)
|
||||||
.chain(alternatives)
|
.chain(alternatives)
|
||||||
.find_map(|p| find_by_exe(p).into_iter().next())
|
.find_map(|p| find_by_exe(p).into_iter().next())
|
||||||
});
|
});
|
||||||
|
|
||||||
let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid);
|
|
||||||
let (state, port_ready, conflict) = match (&proc, owner) {
|
let (state, port_ready, conflict) = match (&proc, owner) {
|
||||||
(Some(p), Some(o)) if p.pid == o => (ServiceState::Running, true, None),
|
(Some(p), Some(o)) if p.pid == o => (ServiceState::Running, true, None),
|
||||||
(Some(_), _) => (ServiceState::Starting, false, None),
|
(Some(_), _) => (ServiceState::Starting, false, None),
|
||||||
|
|||||||
@@ -524,11 +524,14 @@ AC>";
|
|||||||
ra.set_account_password("x", "newpass9").is_err(),
|
ra.set_account_password("x", "newpass9").is_err(),
|
||||||
"the name is validated before anything is sent"
|
"the name is validated before anything is sent"
|
||||||
);
|
);
|
||||||
assert!(ra
|
let error = ra
|
||||||
.set_account_password("Nobody", "newpass9")
|
.set_account_password("Nobody", "newpass9")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string()
|
.to_string();
|
||||||
.contains("not found"));
|
assert!(
|
||||||
|
error.contains("not found"),
|
||||||
|
"Unexpected console error: {error}"
|
||||||
|
);
|
||||||
drop(ra);
|
drop(ra);
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,17 @@ fn introduction_rank(repo: &Path, pathspecs: &[&str]) -> std::collections::HashM
|
|||||||
rank
|
rank
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every SQL script of a core checkout (released + pending updates, module base and update scripts), in application order.
|
/// Incremental SQL of a core checkout. Installation-only module bases must never be replayed on a live database.
|
||||||
pub fn collect_core_sql(core: &Path) -> Result<Vec<SqlFile>> {
|
pub fn collect_core_sql(core: &Path) -> Result<Vec<SqlFile>> {
|
||||||
|
collect_core_sql_mode(core, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clean installation also needs the module tables and initial seed data.
|
||||||
|
pub fn collect_core_install_sql(core: &Path) -> Result<Vec<SqlFile>> {
|
||||||
|
collect_core_sql_mode(core, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_core_sql_mode(core: &Path, include_base: bool) -> Result<Vec<SqlFile>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut push = |db: &str, id: String, abs: PathBuf, origin: String| -> Result<()> {
|
let mut push = |db: &str, id: String, abs: PathBuf, origin: String| -> Result<()> {
|
||||||
let sha256 = fsx::sha256_file(&abs)?;
|
let sha256 = fsx::sha256_file(&abs)?;
|
||||||
@@ -98,7 +107,11 @@ pub fn collect_core_sql(core: &Path) -> Result<Vec<SqlFile>> {
|
|||||||
let mname = name.replace('-', "_");
|
let mname = name.replace('-', "_");
|
||||||
for kind in ["auth", "characters", "world"] {
|
for kind in ["auth", "characters", "world"] {
|
||||||
let base = m.path().join(format!("data/sql/db-{kind}"));
|
let base = m.path().join(format!("data/sql/db-{kind}"));
|
||||||
for f in sql_in(&base.join("base")) {
|
for f in if include_base {
|
||||||
|
sql_in(&base.join("base"))
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
} {
|
||||||
let stem = f.file_stem().unwrap().to_string_lossy().into_owned();
|
let stem = f.file_stem().unwrap().to_string_lossy().into_owned();
|
||||||
let rel = format!(
|
let rel = format!(
|
||||||
"modules/{name}/data/sql/db-{kind}/base/{}",
|
"modules/{name}/data/sql/db-{kind}/base/{}",
|
||||||
@@ -199,6 +212,16 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
|
|||||||
|
|
||||||
/// Build a cumulative update: every file of `tree` that differs from the base, plus every SQL script. Unsigned.
|
/// Build a cumulative update: every file of `tree` that differs from the base, plus every SQL script. Unsigned.
|
||||||
pub fn pack_update(p: &UpdateParams, progress: &dyn Fn(&str)) -> Result<Manifest> {
|
pub fn pack_update(p: &UpdateParams, progress: &dyn Fn(&str)) -> Result<Manifest> {
|
||||||
|
if let Some(file) = p
|
||||||
|
.sql
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.id.starts_with("mod_") && file.id.contains("__base__"))
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"Module installation SQL {} cannot be included in an update package.",
|
||||||
|
file.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
let base: BTreeMap<String, &str> = p
|
let base: BTreeMap<String, &str> = p
|
||||||
.base_manifest
|
.base_manifest
|
||||||
.files
|
.files
|
||||||
@@ -318,7 +341,7 @@ mod tests {
|
|||||||
"modules/mod-x/data/sql/db-world/2026_02_02_00_b.sql",
|
"modules/mod-x/data/sql/db-world/2026_02_02_00_b.sql",
|
||||||
"SELECT 5;",
|
"SELECT 5;",
|
||||||
);
|
);
|
||||||
let v = collect_core_sql(c).unwrap();
|
let v = collect_core_install_sql(c).unwrap();
|
||||||
let ids: Vec<(&str, &str)> = v.iter().map(|f| (f.db.as_str(), f.id.as_str())).collect();
|
let ids: Vec<(&str, &str)> = v.iter().map(|f| (f.db.as_str(), f.id.as_str())).collect();
|
||||||
assert!(
|
assert!(
|
||||||
ids.contains(&("world", "2026_01_01_00"))
|
ids.contains(&("world", "2026_01_01_00"))
|
||||||
@@ -342,6 +365,43 @@ mod tests {
|
|||||||
base_pos < upd_pos,
|
base_pos < upd_pos,
|
||||||
"a module's base script precedes its updates"
|
"a module's base script precedes its updates"
|
||||||
);
|
);
|
||||||
|
let updates = collect_core_sql(c).unwrap();
|
||||||
|
assert!(!updates.iter().any(|f| f.id.contains("__base__")));
|
||||||
|
assert!(updates.iter().any(|f| f.id == "mod_mod_x__2026_02_02_00_b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_packaging_rejects_module_seed_sql_before_creating_output() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let base: Manifest = serde_json::from_value(serde_json::json!({
|
||||||
|
"schema": 1, "kind": "base", "version": "1.0.0", "core": {"commit": null},
|
||||||
|
"builtAt": "test", "minManagerVersion": "0.1.0", "files": []
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let out = d.path().join("out");
|
||||||
|
let sql = vec![SqlFile {
|
||||||
|
db: "world".into(),
|
||||||
|
id: "mod_mod_raid__base__03_boss_schedule".into(),
|
||||||
|
origin: "modules/mod-raid/data/sql/db-world/base/03_boss_schedule.sql".into(),
|
||||||
|
abs: d.path().join("seed.sql"),
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
}];
|
||||||
|
let result = pack_update(
|
||||||
|
&UpdateParams {
|
||||||
|
tree: d.path(),
|
||||||
|
base_manifest: &base,
|
||||||
|
sql: &sql,
|
||||||
|
out: &out,
|
||||||
|
version: "1.1.0".into(),
|
||||||
|
core_commit: None,
|
||||||
|
bots_commit: None,
|
||||||
|
part_size: 1024,
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
assert!(result.unwrap_err().to_string().contains("installation SQL"));
|
||||||
|
assert!(!out.exists());
|
||||||
|
assert!(!d.path().join("out-staging").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -7,10 +7,286 @@ use crate::{
|
|||||||
schema_check, Error, Result,
|
schema_check, Error, Result,
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
const MARKER: &str = ".release-schema-fixture";
|
const MARKER: &str = ".release-schema-fixture";
|
||||||
|
|
||||||
|
struct QualificationEnv<'a>(crate::update::RepackEnv<'a>);
|
||||||
|
|
||||||
|
impl crate::update::Env for QualificationEnv<'_> {
|
||||||
|
fn activate(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn ensure_stopped(&self) -> Result<()> {
|
||||||
|
self.0.ensure_stopped()
|
||||||
|
}
|
||||||
|
fn preflight(&self, manifest: &crate::manifest::Manifest) -> Result<()> {
|
||||||
|
self.0.preflight(manifest)
|
||||||
|
}
|
||||||
|
fn snapshot(&self) -> Result<String> {
|
||||||
|
self.0.snapshot()
|
||||||
|
}
|
||||||
|
fn verify_snapshot(&self, id: &str) -> Result<()> {
|
||||||
|
self.0.verify_snapshot(id)
|
||||||
|
}
|
||||||
|
fn restore_snapshot(&self, id: &str) -> Result<()> {
|
||||||
|
self.0.restore_snapshot(id)
|
||||||
|
}
|
||||||
|
fn migrate(
|
||||||
|
&self,
|
||||||
|
manifest: &crate::manifest::Manifest,
|
||||||
|
staged: &Path,
|
||||||
|
) -> Result<crate::migrations::ApplyReport> {
|
||||||
|
self.0.migrate(manifest, staged)
|
||||||
|
}
|
||||||
|
fn validate(&self) -> Result<()> {
|
||||||
|
self.0.validate()
|
||||||
|
}
|
||||||
|
fn validate_recovery(&self) -> Result<()> {
|
||||||
|
self.0.validate_recovery()
|
||||||
|
}
|
||||||
|
fn recover_validation(&self) -> Result<()> {
|
||||||
|
self.0.recover_validation()
|
||||||
|
}
|
||||||
|
fn automatic_rollback(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Health checks use the normal private validation; qualification never opens game ports after commit.
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn qualify_upgrade(
|
||||||
|
base: &Path,
|
||||||
|
source: &Path,
|
||||||
|
candidate: &Path,
|
||||||
|
fixture: &Path,
|
||||||
|
trusted_key: &str,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
use crate::update::Env;
|
||||||
|
if !cfg!(windows) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Manager upgrade qualification requires a Windows fixture.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if fixture.exists()
|
||||||
|
|| !fixture
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-"))
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Upgrade qualification requires a new disposable coa-schema-fixture-* directory."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fixture_root = std::path::absolute(fixture)?;
|
||||||
|
let fixture = fixture_root.as_path();
|
||||||
|
let base_root = fsx::canonicalize_lenient(base)?;
|
||||||
|
let base = base_root.as_path();
|
||||||
|
let source_root = fsx::canonicalize_lenient(source)?;
|
||||||
|
let source = source_root.as_path();
|
||||||
|
let candidate_root = fsx::canonicalize_lenient(candidate)?;
|
||||||
|
let candidate = candidate_root.as_path();
|
||||||
|
let metadata = crate::registry::metadata_dir_for(fixture)?;
|
||||||
|
if metadata.exists() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Upgrade fixture metadata already exists.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (base_manifest, base_bytes) =
|
||||||
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(base.into()), trusted_key)?;
|
||||||
|
let (source_manifest, source_bytes) =
|
||||||
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(source.into()), trusted_key)?;
|
||||||
|
let (candidate_manifest, _) =
|
||||||
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?;
|
||||||
|
if candidate_manifest.kind != Kind::Update
|
||||||
|
|| !matches!(source_manifest.kind, Kind::Base | Kind::Update)
|
||||||
|
|| (source_manifest.kind == Kind::Base
|
||||||
|
&& fsx::sha256_bytes(&base_bytes) != fsx::sha256_bytes(&source_bytes))
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Invalid source or candidate package for upgrade qualification.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extract_base(base, fixture, trusted_key)?;
|
||||||
|
package::extract_selected(
|
||||||
|
base,
|
||||||
|
&base_manifest,
|
||||||
|
fixture,
|
||||||
|
&|path| path.starts_with("Data/"),
|
||||||
|
&|_, _| {},
|
||||||
|
)?;
|
||||||
|
let mut meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, fixture);
|
||||||
|
meta.core.version = Some(base_manifest.version.clone());
|
||||||
|
meta.core.commit = base_manifest.core.commit.clone();
|
||||||
|
meta.original_hashes = base_manifest
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| (file.path.clone(), file.sha256.clone()))
|
||||||
|
.collect();
|
||||||
|
crate::registry::MetaDir::create(fixture, &meta)?;
|
||||||
|
let env = QualificationEnv(crate::update::RepackEnv {
|
||||||
|
root: fixture,
|
||||||
|
meta_dir: &metadata,
|
||||||
|
});
|
||||||
|
let apply = |package: &Path| -> Result<()> {
|
||||||
|
let outcome = crate::update::apply(
|
||||||
|
&crate::update::Params {
|
||||||
|
root: fixture,
|
||||||
|
meta_dir: &metadata,
|
||||||
|
source: pkgsource::Source::Dir(package.into()),
|
||||||
|
trusted_key,
|
||||||
|
cancel: crate::download::Cancel::default(),
|
||||||
|
resolutions: Default::default(),
|
||||||
|
env: &env,
|
||||||
|
fail_after_ops: None,
|
||||||
|
},
|
||||||
|
&|_, _| {},
|
||||||
|
)?;
|
||||||
|
if outcome.txn.state != crate::update::State::Committed {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Upgrade qualification did not commit.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
env.ensure_stopped()
|
||||||
|
};
|
||||||
|
install::with_scratch_ports(fixture, || {
|
||||||
|
if source_manifest.kind == Kind::Update {
|
||||||
|
apply(source)?;
|
||||||
|
}
|
||||||
|
let user_file = fixture.join("Core/configs/coa-qualification-user.conf");
|
||||||
|
let user_bytes = b"Qualification.UserSetting = 7391\r\n";
|
||||||
|
fsx::atomic_write(&user_file, user_bytes)?;
|
||||||
|
apply(candidate)?;
|
||||||
|
if std::fs::read(&user_file)?.as_slice() != user_bytes {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"The update changed a user configuration file.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (_, installed) = crate::registry::MetaDir::open(&metadata)?;
|
||||||
|
if installed.core.version.as_deref() != Some(candidate_manifest.version.as_str())
|
||||||
|
|| crate::update_isolation::pending(&metadata)
|
||||||
|
|| crate::update::pending_checked(&metadata)?.is_some()
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"The update left inconsistent metadata or an unfinished transaction.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"schema": 1, "result": "passed", "fromVersion": source_manifest.version,
|
||||||
|
"toVersion": candidate_manifest.version, "fromManifestSha256": fsx::sha256_bytes(&source_bytes),
|
||||||
|
"fromSchemaSha256": source_manifest.files.iter().find(|file| file.path == schema_check::CONTRACT).map(|file| &file.sha256),
|
||||||
|
"checks": ["manager-transaction", "recovery-backed-database-update", "database-schema", "private-auth-world-startup", "existing-identities", "user-file-preservation"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compose_install(
|
||||||
|
base: &Path,
|
||||||
|
candidate: &Path,
|
||||||
|
fixture: &Path,
|
||||||
|
trusted_key: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
qualify_upgrade(base, base, candidate, fixture, trusted_key)?;
|
||||||
|
let fixture = fsx::canonicalize_lenient(fixture)?;
|
||||||
|
let (_, bytes) =
|
||||||
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?;
|
||||||
|
let manifest = crate::manifest::Manifest::parse(&bytes)?;
|
||||||
|
install::with_scratch_ports(&fixture, || {
|
||||||
|
backup::with_database(&fixture, |db| {
|
||||||
|
let players = db.query(&format!(
|
||||||
|
"SELECT COUNT(*) FROM acore_characters.characters WHERE account<>{};",
|
||||||
|
crate::companions::TEMPLATE_ACCOUNT
|
||||||
|
))?;
|
||||||
|
if players.trim() != "0" || crate::companions::template_count(db)? != 21 {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"The installation image contains unexpected player characters.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for table in crate::cleanbase::AUTH_PLAYER_TABLES {
|
||||||
|
if db.tables("acore_auth")?.iter().any(|name| name == table) {
|
||||||
|
db.query(&format!("DELETE FROM acore_auth.`{table}`;"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(problem) = schema_check::check(db, &fixture)?.first() {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"Installation image schema mismatch: {}.{}",
|
||||||
|
problem.table, problem.column
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
fs::remove_file(fixture.join("Core/configs/coa-qualification-user.conf"))?;
|
||||||
|
let path = fixture.join("Settings/repack.json");
|
||||||
|
let mut settings: serde_json::Value = fsx::read_json(&path)?;
|
||||||
|
settings["raPassword"] = "".into();
|
||||||
|
fsx::atomic_write_json(&path, &settings)?;
|
||||||
|
fsx::atomic_write_json(
|
||||||
|
&fixture.join("Settings/install-source.json"),
|
||||||
|
&serde_json::json!({
|
||||||
|
"version": manifest.version, "core": manifest.core, "bots": manifest.bots,
|
||||||
|
"updateManifestSha256": fsx::sha256_bytes(&bytes),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
crate::mysql_snapshot::stop(&fixture)?;
|
||||||
|
fs::remove_file(fixture.join(MARKER))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn qualify_install(
|
||||||
|
package: &Path,
|
||||||
|
fixture: &Path,
|
||||||
|
trusted_key: &str,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
use crate::update::Env;
|
||||||
|
if !cfg!(windows)
|
||||||
|
|| fixture.exists()
|
||||||
|
|| !fixture
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-"))
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Clean installation qualification requires a new disposable Windows fixture.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fixture = std::path::absolute(fixture)?;
|
||||||
|
let package = fsx::canonicalize_lenient(package)?;
|
||||||
|
let (manifest, bytes) =
|
||||||
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(package.clone()), trusted_key)?;
|
||||||
|
let registry_dir = tempfile::tempdir()?;
|
||||||
|
let registry = crate::registry::Registry::at(registry_dir.path().join("registry.json"));
|
||||||
|
let installed = install::install_base(
|
||||||
|
&install::Params {
|
||||||
|
source: pkgsource::Source::Dir(package),
|
||||||
|
dest: fixture.clone(),
|
||||||
|
trusted_key,
|
||||||
|
registry: ®istry,
|
||||||
|
cancel: crate::download::Cancel::default(),
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
)?;
|
||||||
|
let metadata = crate::registry::metadata_dir_for(&fixture)?;
|
||||||
|
let env = QualificationEnv(crate::update::RepackEnv {
|
||||||
|
root: &fixture,
|
||||||
|
meta_dir: &metadata,
|
||||||
|
});
|
||||||
|
env.validate()?;
|
||||||
|
env.ensure_stopped()?;
|
||||||
|
crate::mysql_snapshot::stop(&fixture)?;
|
||||||
|
if installed.version != manifest.version || crate::update::pending_checked(&metadata)?.is_some()
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Clean installation did not finish at the packaged release.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
serde_json::json!({ "schema": 1, "result": "passed", "version": installed.version,
|
||||||
|
"manifestSha256": fsx::sha256_bytes(&bytes),
|
||||||
|
"checks": ["clean-install", "signed-full-package", "credential-rotation", "schema-contract", "private-auth-world-startup"] }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn collect(core: &Path, bots: Option<&Path>, repairs: Option<&Path>) -> Result<Vec<SqlFile>> {
|
pub fn collect(core: &Path, bots: Option<&Path>, repairs: Option<&Path>) -> Result<Vec<SqlFile>> {
|
||||||
let mut files = release::collect_core_sql(core)?;
|
let mut files = release::collect_core_sql(core)?;
|
||||||
if let Some(bots) = bots {
|
if let Some(bots) = bots {
|
||||||
@@ -154,9 +430,139 @@ pub fn capture_release(fixture: &Path, tree: &Path, sql: &[SqlFile]) -> Result<(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> {
|
pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: &str) -> Result<()> {
|
||||||
|
use crate::update::Env;
|
||||||
|
if !cfg!(windows) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Repack startup qualification requires a Windows runner.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !fixture
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|n| n.to_string_lossy().starts_with("coa-schema-fixture-"))
|
||||||
|
|| std::fs::read(fixture.join(MARKER)).ok().as_deref()
|
||||||
|
!= Some(b"disposable release-schema fixture")
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup qualification requires a disposable release-schema fixture.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fixture_root = fsx::canonicalize_lenient(fixture)?;
|
||||||
|
let fixture = fixture_root.as_path();
|
||||||
|
let tree_root = fsx::canonicalize_lenient(tree)?;
|
||||||
|
let tree = tree_root.as_path();
|
||||||
|
let metadata = crate::registry::metadata_dir_for(fixture)?;
|
||||||
|
if metadata.exists() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup fixture already has Manager metadata; use a fresh fixture.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
schema_check::require_release_contract(tree)?;
|
||||||
|
for name in ["worldserver.exe", "authserver.exe"] {
|
||||||
|
if !tree.join("Core").join(name).is_file() {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"Startup candidate is missing {name}."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
let (manifest, _) =
|
let (manifest, _) =
|
||||||
pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?;
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(base.into()), trusted_key)?;
|
||||||
|
if manifest.kind != Kind::Base {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup qualification requires a signed base package.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut directories = vec![tree.to_path_buf()];
|
||||||
|
let mut files = Vec::new();
|
||||||
|
while let Some(directory) = directories.pop() {
|
||||||
|
for entry in std::fs::read_dir(directory)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let kind = entry.file_type()?;
|
||||||
|
if kind.is_symlink() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup release tree must not contain symbolic links.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
fsx::ensure_within(tree, &path)?;
|
||||||
|
if kind.is_dir() {
|
||||||
|
directories.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !kind.is_file() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup release tree contains an unsupported file type.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(tree)
|
||||||
|
.map_err(|e| Error::Invalid(e.to_string()))?
|
||||||
|
.to_path_buf();
|
||||||
|
let first = relative
|
||||||
|
.components()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.as_os_str()
|
||||||
|
.to_string_lossy();
|
||||||
|
if !matches!(
|
||||||
|
first.as_ref(),
|
||||||
|
"Core" | "Scripts" | "Data" | "Extras" | "Licenses"
|
||||||
|
) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup release tree contains installation-specific files.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fsx::ensure_within(fixture, &fixture.join(&relative))?;
|
||||||
|
files.push(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
package::extract_selected(
|
||||||
|
base,
|
||||||
|
&manifest,
|
||||||
|
fixture,
|
||||||
|
&|path| path.starts_with("Data/"),
|
||||||
|
&|_, _| {},
|
||||||
|
)?;
|
||||||
|
for relative in files {
|
||||||
|
let target = fsx::ensure_within(fixture, &fixture.join(&relative))?;
|
||||||
|
fsx::atomic_write(&target, &std::fs::read(tree.join(&relative))?)?;
|
||||||
|
let expected = fsx::sha256_file(&tree.join(relative))?;
|
||||||
|
if fsx::sha256_file(&target)? != expected {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup candidate copy failed verification.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, fixture);
|
||||||
|
crate::registry::MetaDir::create(fixture, &meta)?;
|
||||||
|
let env = crate::update::RepackEnv {
|
||||||
|
root: fixture,
|
||||||
|
meta_dir: &metadata,
|
||||||
|
};
|
||||||
|
env.validate()?;
|
||||||
|
env.ensure_stopped()?;
|
||||||
|
if crate::update_isolation::pending(&metadata) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Startup qualification left unfinished isolated validation.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> {
|
||||||
|
verify_package_source(dir, &pkgsource::Source::Dir(dir.into()), trusted_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_install_package(dir: &Path, trusted_key: &str) -> Result<()> {
|
||||||
|
verify_package_source(
|
||||||
|
dir,
|
||||||
|
&pkgsource::Source::Installation(Box::new(pkgsource::Source::Dir(dir.into()))),
|
||||||
|
trusted_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_package_source(dir: &Path, source: &pkgsource::Source, trusted_key: &str) -> Result<()> {
|
||||||
|
let (manifest, _) = pkgsource::fetch_manifest(source, trusted_key)?;
|
||||||
if !manifest
|
if !manifest
|
||||||
.files
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -178,12 +584,116 @@ pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn verify_upgrade_fixture(dir: &Path, trusted_key: &str) -> Result<()> {
|
||||||
|
let (manifest, _) =
|
||||||
|
pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?;
|
||||||
|
if !matches!(manifest.kind, Kind::Base | Kind::Update) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Upgrade fixtures must be base or server update packages.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let scratch = tempfile::tempdir()?;
|
||||||
|
package::extract_selected(
|
||||||
|
dir,
|
||||||
|
&manifest,
|
||||||
|
scratch.path(),
|
||||||
|
&|path| path == schema_check::CONTRACT,
|
||||||
|
&|_, _| {},
|
||||||
|
)?;
|
||||||
|
if manifest.kind == Kind::Update
|
||||||
|
|| manifest
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == schema_check::CONTRACT)
|
||||||
|
{
|
||||||
|
schema_check::require_release_contract(scratch.path())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
use ed25519_dalek::{Signer, SigningKey};
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "Requires a composed disposable installation image"]
|
||||||
|
fn real_full_package_installs_the_current_version_without_an_update_transaction() {
|
||||||
|
let image = std::path::PathBuf::from(
|
||||||
|
std::env::var_os("COA_INSTALL_IMAGE_DIR").expect("COA_INSTALL_IMAGE_DIR"),
|
||||||
|
);
|
||||||
|
let provenance: serde_json::Value =
|
||||||
|
fsx::read_json(&image.join("Settings/install-source.json")).unwrap();
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let package = root.path().join("package");
|
||||||
|
let options = package::BuildOptions {
|
||||||
|
kind: Kind::Base,
|
||||||
|
version: provenance["version"].as_str().unwrap().into(),
|
||||||
|
core_commit: provenance["core"]["commit"].as_str().map(String::from),
|
||||||
|
bots_commit: provenance["bots"]["commit"].as_str().map(String::from),
|
||||||
|
built_at: "qualification".into(),
|
||||||
|
part_size: package::DEFAULT_PART_SIZE,
|
||||||
|
migrations: vec![],
|
||||||
|
};
|
||||||
|
package::build(&image, &package, &options, &|_| {}).unwrap();
|
||||||
|
let signing_key = SigningKey::generate(&mut rand_core::OsRng);
|
||||||
|
let bytes = fs::read(package.join("manifest.json")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
package.join("manifest.json.sig"),
|
||||||
|
STANDARD.encode(signing_key.sign(&bytes).to_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let trusted_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||||
|
let fixture = root.path().join("coa-schema-fixture-clean-install");
|
||||||
|
let report = qualify_install(&package, &fixture, &trusted_key).unwrap();
|
||||||
|
assert_eq!(report["version"], provenance["version"]);
|
||||||
|
assert_eq!(report["result"], "passed");
|
||||||
|
let metadata = crate::registry::metadata_dir_for(&fixture).unwrap();
|
||||||
|
let (_, installed) = crate::registry::MetaDir::open(&metadata).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
installed.core.version.as_deref(),
|
||||||
|
provenance["version"].as_str()
|
||||||
|
);
|
||||||
|
assert!(!metadata
|
||||||
|
.join("updates")
|
||||||
|
.read_dir()
|
||||||
|
.is_ok_and(|mut entries| entries.next().is_some()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_startup_fixture_targets_replace_the_original_location() {
|
||||||
|
let directory = tempfile::Builder::new()
|
||||||
|
.prefix("coa-schema-fixture-path-test-")
|
||||||
|
.tempdir_in(".")
|
||||||
|
.unwrap();
|
||||||
|
let current = std::env::current_dir().unwrap();
|
||||||
|
let relative = directory.path().strip_prefix(¤t).unwrap();
|
||||||
|
fsx::atomic_write(&relative.join("Core/worldserver.exe"), b"old").unwrap();
|
||||||
|
let root = fsx::canonicalize_lenient(relative).unwrap();
|
||||||
|
let target = fsx::ensure_within(&root, &root.join("Core/worldserver.exe")).unwrap();
|
||||||
|
fsx::atomic_write(&target, b"candidate").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(relative.join("Core/worldserver.exe")).unwrap(),
|
||||||
|
b"candidate"
|
||||||
|
);
|
||||||
|
assert!(!root.join(relative).join("Core/worldserver.exe").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_qualification_refuses_real_server_folders_before_writes() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let server = directory.path().join("live-server");
|
||||||
|
std::fs::create_dir(&server).unwrap();
|
||||||
|
fsx::atomic_write(&server.join("owner-data"), b"untouched").unwrap();
|
||||||
|
assert!(validate_startup(&server, directory.path(), directory.path(), "unused").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(server.join("owner-data")).unwrap(),
|
||||||
|
b"untouched"
|
||||||
|
);
|
||||||
|
assert!(!crate::registry::metadata_dir_for(&server).unwrap().exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn signed_packages_must_carry_a_real_complete_schema_contract() {
|
fn signed_packages_must_carry_a_real_complete_schema_contract() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -252,6 +762,45 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
assert!(extract_base(dir.path(), dir.path(), "irrelevant").is_err());
|
assert!(extract_base(dir.path(), dir.path(), "irrelevant").is_err());
|
||||||
assert!(capture_release(dir.path(), dir.path(), &[]).is_err());
|
assert!(capture_release(dir.path(), dir.path(), &[]).is_err());
|
||||||
|
assert!(
|
||||||
|
qualify_upgrade(dir.path(), dir.path(), dir.path(), dir.path(), "irrelevant").is_err()
|
||||||
|
);
|
||||||
assert!(!dir.path().join(schema_check::CONTRACT).exists());
|
assert!(!dir.path().join(schema_check::CONTRACT).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_legacy_base_fixtures_may_omit_the_schema_contract() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let tree = dir.path().join("tree");
|
||||||
|
let out = dir.path().join("package");
|
||||||
|
fsx::atomic_write(&tree.join("Core/example.txt"), b"fixture").unwrap();
|
||||||
|
let key = SigningKey::from_bytes(&[29; 32]);
|
||||||
|
let public = STANDARD.encode(key.verifying_key().as_bytes());
|
||||||
|
let options = package::BuildOptions {
|
||||||
|
kind: Kind::Base,
|
||||||
|
version: "0.2.0".into(),
|
||||||
|
core_commit: None,
|
||||||
|
bots_commit: None,
|
||||||
|
built_at: "test".into(),
|
||||||
|
part_size: 1024 * 1024,
|
||||||
|
migrations: vec![],
|
||||||
|
};
|
||||||
|
package::build(&tree, &out, &options, &|_| {}).unwrap();
|
||||||
|
let sign = || {
|
||||||
|
let bytes = std::fs::read(out.join("manifest.json")).unwrap();
|
||||||
|
fsx::atomic_write(
|
||||||
|
&out.join("manifest.json.sig"),
|
||||||
|
STANDARD.encode(key.sign(&bytes).to_bytes()).as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
sign();
|
||||||
|
verify_upgrade_fixture(&out, &public).unwrap();
|
||||||
|
assert!(verify_package(&out, &public).is_err());
|
||||||
|
let mut manifest: serde_json::Value = fsx::read_json(&out.join("manifest.json")).unwrap();
|
||||||
|
manifest["kind"] = "update".into();
|
||||||
|
fsx::atomic_write_json(&out.join("manifest.json"), &manifest).unwrap();
|
||||||
|
sign();
|
||||||
|
assert!(verify_upgrade_fixture(&out, &public).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -51,10 +51,14 @@ pub fn check(root: &Path, dir: &Path) -> Result<Vec<DatabaseCheck>> {
|
|||||||
.filter(|m| mode == crate::realms::Mode::Coa || m.db != "auth")
|
.filter(|m| mode == crate::realms::Mode::Coa || m.db != "auth")
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut problems = crate::schema_check::check(&db, root)?;
|
||||||
|
problems.extend(crate::squid::database_problems(root, mode, &|sql| {
|
||||||
|
db.query(sql)
|
||||||
|
})?);
|
||||||
checks.push(DatabaseCheck {
|
checks.push(DatabaseCheck {
|
||||||
realm: mode,
|
realm: mode,
|
||||||
migrations: crate::migrations::status(&db, &migrations)?,
|
migrations: crate::migrations::status(&db, &migrations)?,
|
||||||
problems: crate::schema_check::check(&db, root)?,
|
problems,
|
||||||
full_schema: root.join(crate::schema_check::CONTRACT).exists(),
|
full_schema: root.join(crate::schema_check::CONTRACT).exists(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -222,59 +226,93 @@ pub fn run(
|
|||||||
root,
|
root,
|
||||||
dir,
|
dir,
|
||||||
crate::backup::Kind::Full,
|
crate::backup::Kind::Full,
|
||||||
crate::backup::Trigger::Manual,
|
crate::backup::Trigger::BeforeRepair,
|
||||||
Some("before repair".into()),
|
Some("before repair".into()),
|
||||||
&|_| {},
|
&|phase| progress(phase, 65),
|
||||||
)?
|
)?
|
||||||
.id;
|
.id;
|
||||||
env.verify_recovery_point(&crate::backup::get(dir, &backup)?)?;
|
env.verify_recovery_point(&crate::backup::get(dir, &backup)?)?;
|
||||||
let repair_files: Vec<_> = broken.iter().map(|(file, _)| (*file).clone()).collect();
|
let repair_files: Vec<_> = broken.iter().map(|(file, _)| (*file).clone()).collect();
|
||||||
let mut txn =
|
let mut txn =
|
||||||
crate::update::begin_repair(root, dir, &update.version, &backup, &repair_files)?;
|
crate::update::begin_repair(root, dir, &update.version, &backup, &repair_files)?;
|
||||||
let before = dir.join("repairs").join(&backup);
|
let attempt = (|| -> Result<Report> {
|
||||||
fs::create_dir_all(&before)?;
|
let before = dir.join("repairs").join(&backup);
|
||||||
fsx::require_space(
|
fs::create_dir_all(&before)?;
|
||||||
root,
|
fsx::require_space(
|
||||||
broken.iter().map(|(e, _)| e.size.saturating_mul(2)).sum(),
|
root,
|
||||||
)?;
|
broken.iter().map(|(e, _)| e.size.saturating_mul(2)).sum(),
|
||||||
let restored = restore_files(root, &work, &before, &broken)?;
|
)?;
|
||||||
for (entry, _) in &broken {
|
let restored = restore_files(root, &work, &before, &broken)?;
|
||||||
meta.original_hashes
|
for (entry, _) in &broken {
|
||||||
.insert(entry.path.clone(), entry.sha256.clone());
|
meta.original_hashes
|
||||||
}
|
.insert(entry.path.clone(), entry.sha256.clone());
|
||||||
fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
|
}
|
||||||
progress("Repairing pending database updates", 80);
|
fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
|
||||||
crate::update::repair_migrating(dir, &mut txn)?;
|
progress("Repairing pending database updates", 80);
|
||||||
let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) {
|
crate::update::repair_migrating(dir, &mut txn)?;
|
||||||
Ok(r) => (r.applied, r.failed.map(|(id, why)| format!("{id}: {why}"))),
|
let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) {
|
||||||
Err(e) => (Vec::new(), Some(e.to_string())),
|
Ok(r) => (r.applied, r.failed.map(|(id, why)| format!("{id}: {why}"))),
|
||||||
};
|
Err(e) => (Vec::new(), Some(e.to_string())),
|
||||||
if error.is_some() {
|
};
|
||||||
crate::update::finish_repair(root, dir, &mut txn, false, &env)?;
|
if error.is_some() {
|
||||||
|
crate::update::finish_repair(root, dir, &mut txn, false, &env)?;
|
||||||
|
let report = Report {
|
||||||
|
restored: Vec::new(),
|
||||||
|
applied: Vec::new(),
|
||||||
|
backup: backup.clone(),
|
||||||
|
database: check(root, dir)?,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
fsx::atomic_write_json(&before.join("report.json"), &report)?;
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
crate::config::create_missing_module_configs(root)?;
|
||||||
|
progress("Checking database after repair", 95);
|
||||||
|
let database = check(root, dir)?;
|
||||||
let report = Report {
|
let report = Report {
|
||||||
restored: Vec::new(),
|
restored,
|
||||||
applied: Vec::new(),
|
applied,
|
||||||
backup,
|
backup: backup.clone(),
|
||||||
database: check(root, dir)?,
|
database,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
fsx::atomic_write_json(&before.join("report.json"), &report)?;
|
fsx::atomic_write_json(&before.join("report.json"), &report)?;
|
||||||
return Ok(report);
|
crate::update::finish_repair(root, dir, &mut txn, true, &env)?;
|
||||||
|
progress("Done", 100);
|
||||||
|
Ok(report)
|
||||||
|
})();
|
||||||
|
match attempt {
|
||||||
|
Ok(report) => Ok(report),
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
txn.state,
|
||||||
|
crate::update::State::RolledBack | crate::update::State::Failed
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
progress("Undoing the failed repair", 95);
|
||||||
|
let message = match crate::update::finish_repair(root, dir, &mut txn, false, &env) {
|
||||||
|
Ok(()) => format!("Repair failed: {error}. The original server files and databases were restored from recovery point {backup}."),
|
||||||
|
Err(recovery) => format!("Repair failed: {error}. Recovery failed: {recovery}. Recovery point {backup} was preserved; resolve the unfinished repair before starting the server."),
|
||||||
|
};
|
||||||
|
let failed = Report {
|
||||||
|
restored: Vec::new(),
|
||||||
|
applied: Vec::new(),
|
||||||
|
backup: backup.clone(),
|
||||||
|
database: Vec::new(),
|
||||||
|
error: Some(message.clone()),
|
||||||
|
};
|
||||||
|
if let Err(report_error) = fsx::atomic_write_json(
|
||||||
|
&dir.join("repairs").join(&backup).join("report.json"),
|
||||||
|
&failed,
|
||||||
|
) {
|
||||||
|
tracing::warn!(%report_error, "Could not persist failed repair report");
|
||||||
|
}
|
||||||
|
Err(Error::Invalid(message))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
crate::config::create_missing_module_configs(root)?;
|
|
||||||
progress("Checking database after repair", 95);
|
|
||||||
let database = check(root, dir)?;
|
|
||||||
let report = Report {
|
|
||||||
restored,
|
|
||||||
applied,
|
|
||||||
backup,
|
|
||||||
database,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
fsx::atomic_write_json(&before.join("report.json"), &report)?;
|
|
||||||
crate::update::finish_repair(root, dir, &mut txn, true, &env)?;
|
|
||||||
progress("Done", 100);
|
|
||||||
Ok(report)
|
|
||||||
})();
|
})();
|
||||||
// Only the UUID staging directory created above is discarded; backups are retained.
|
// Only the UUID staging directory created above is discarded; backups are retained.
|
||||||
let _ = fs::remove_dir_all(work);
|
let _ = fs::remove_dir_all(work);
|
||||||
|
|||||||
@@ -93,6 +93,57 @@ pub struct Problem {
|
|||||||
pub detail: String,
|
pub detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep all differences for support; never change a database to match a contract automatically.
|
||||||
|
pub fn check_with_report(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
||||||
|
let problems = check(db, root)?;
|
||||||
|
if problems.is_empty() {
|
||||||
|
return Ok(problems);
|
||||||
|
}
|
||||||
|
let report = serde_json::json!({
|
||||||
|
"schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"realm": db.realm().name(), "problems": problems,
|
||||||
|
});
|
||||||
|
let path = crate::registry::metadata_dir_for(root)?
|
||||||
|
.join("diagnostics")
|
||||||
|
.join(format!(
|
||||||
|
"schema-validation-{}-{}.json",
|
||||||
|
db.realm().name(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
// A log write failure must neither hide the original mismatch nor accept an invalid schema.
|
||||||
|
if let Err(error) = fsx::atomic_write_json(&path, &report) {
|
||||||
|
tracing::warn!(%error, path = %path.display(), "Could not save database schema report");
|
||||||
|
}
|
||||||
|
Ok(problems)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe_signature(signature: &str) -> String {
|
||||||
|
let fields: Vec<_> = signature.split('|').collect();
|
||||||
|
if fields.len() != 4 {
|
||||||
|
return signature.into();
|
||||||
|
}
|
||||||
|
let decode = |value: &str| -> String {
|
||||||
|
if value == "<NULL>" {
|
||||||
|
return "no default".into();
|
||||||
|
}
|
||||||
|
if value == "<NONE>" {
|
||||||
|
return "none".into();
|
||||||
|
}
|
||||||
|
hex::decode(value)
|
||||||
|
.ok()
|
||||||
|
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||||
|
.map(|text| format!("{text:?}"))
|
||||||
|
.unwrap_or_else(|| value.into())
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"type={}, nullable={}, default={}, extra={}",
|
||||||
|
fields[0],
|
||||||
|
fields[1],
|
||||||
|
decode(fields[2]),
|
||||||
|
decode(fields[3])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_columns(db: &Db, kind: &str) -> Result<BTreeMap<String, BTreeMap<String, String>>> {
|
fn read_columns(db: &Db, kind: &str) -> Result<BTreeMap<String, BTreeMap<String, String>>> {
|
||||||
let schema = db.realm_schema(crate::db::schema_of(kind)?);
|
let schema = db.realm_schema(crate::db::schema_of(kind)?);
|
||||||
let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'<NULL>',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','<NONE>',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?;
|
let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'<NULL>',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','<NONE>',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?;
|
||||||
@@ -117,6 +168,82 @@ pub fn capture(db: &Db, root: &Path) -> Result<()> {
|
|||||||
fsx::atomic_write_json(&root.join(CONTRACT), &Contract { schema: 1, columns })
|
fsx::atomic_write_json(&root.join(CONTRACT), &Contract { schema: 1, columns })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn missing_default(expected: &str, actual: &str) -> Option<String> {
|
||||||
|
let expected: Vec<_> = expected.split('|').collect();
|
||||||
|
let actual: Vec<_> = actual.split('|').collect();
|
||||||
|
if expected.len() != 4
|
||||||
|
|| actual.len() != 4
|
||||||
|
|| expected[0] != actual[0]
|
||||||
|
|| expected[1] != "NO"
|
||||||
|
|| actual[1] != "NO"
|
||||||
|
|| expected[3] != "<NONE>"
|
||||||
|
|| actual[3] != "<NONE>"
|
||||||
|
|| actual[2] != "<NULL>"
|
||||||
|
|| matches!(expected[2], "<NULL>" | "<NONE>")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8(hex::decode(expected[2]).ok()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only restore a missing literal default from the exact signed target contract. Never alter data or custom defaults.
|
||||||
|
pub(crate) fn repair_missing_defaults(
|
||||||
|
db: &Db,
|
||||||
|
root: &Path,
|
||||||
|
signed_hash: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(signed_hash) = signed_hash else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if fsx::sha256_file(&root.join(CONTRACT))? != signed_hash {
|
||||||
|
return Err(crate::Error::Invalid(
|
||||||
|
"The target schema contract differs from the signed update.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let contract = require_release_contract(root)?;
|
||||||
|
let mut repaired = Vec::new();
|
||||||
|
let quote = |identifier: &str| format!("`{}`", identifier.replace('`', "``"));
|
||||||
|
for (kind, tables) in &contract.columns {
|
||||||
|
let schema = db.realm_schema(crate::db::schema_of(kind)?);
|
||||||
|
let actual = read_columns(db, kind)?;
|
||||||
|
let base_tables: BTreeSet<_> = db
|
||||||
|
.tables(crate::db::schema_of(kind)?)?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
for (table, columns) in tables {
|
||||||
|
if !base_tables.contains(table) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
table.as_str(),
|
||||||
|
"updates" | "updates_include" | "coa_manager_migrations"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (column, expected) in columns {
|
||||||
|
let Some(found) = actual.get(table).and_then(|columns| columns.get(column)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(value) = missing_default(expected, found) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
db.query(&format!("SET SESSION sql_mode=CONCAT_WS(',',@@SESSION.sql_mode,'NO_BACKSLASH_ESCAPES'); ALTER TABLE {}.{} ALTER COLUMN {} SET DEFAULT '{}';", quote(schema), quote(table), quote(column), value.replace('\'', "''")))?;
|
||||||
|
repaired.push(serde_json::json!({"database":schema,"table":table,"column":column,"action":"restore-missing-default"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !repaired.is_empty() {
|
||||||
|
let path = crate::registry::metadata_dir_for(root)?
|
||||||
|
.join("diagnostics")
|
||||||
|
.join(format!("database-repair-{}.json", uuid::Uuid::new_v4()));
|
||||||
|
fsx::atomic_write_json(
|
||||||
|
&path,
|
||||||
|
&serde_json::json!({"schema":1,"checkedAt":chrono::Utc::now().to_rfc3339(),"contractSha256":signed_hash,"repairs":repaired}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
pub fn check(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
||||||
let contract = if root.join(CONTRACT).is_file() {
|
let contract = if root.join(CONTRACT).is_file() {
|
||||||
let contract: Contract = fsx::read_json(&root.join(CONTRACT))?;
|
let contract: Contract = fsx::read_json(&root.join(CONTRACT))?;
|
||||||
@@ -200,7 +327,13 @@ pub fn check(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
|||||||
table: table.clone(),
|
table: table.clone(),
|
||||||
column: column.clone(),
|
column: column.clone(),
|
||||||
detail: found
|
detail: found
|
||||||
.map(|s| format!("Expected {want}, found {s}"))
|
.map(|s| {
|
||||||
|
format!(
|
||||||
|
"Expected {}, found {}",
|
||||||
|
describe_signature(want),
|
||||||
|
describe_signature(s)
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or_else(|| "Missing column".into()),
|
.unwrap_or_else(|| "Missing column".into()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -269,6 +402,38 @@ fn missing_coa_starts(rows: &str) -> Vec<Problem> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn default_repair_only_accepts_missing_literal_defaults_without_other_schema_changes() {
|
||||||
|
assert_eq!(
|
||||||
|
missing_default("int unsigned|NO|30|<NONE>", "int unsigned|NO|<NULL>|<NONE>"),
|
||||||
|
Some("0".into())
|
||||||
|
);
|
||||||
|
for actual in [
|
||||||
|
"int unsigned|NO|3939|<NONE>",
|
||||||
|
"int|NO|<NULL>|<NONE>",
|
||||||
|
"int unsigned|YES|<NULL>|<NONE>",
|
||||||
|
"int unsigned|NO|<NULL>|6175746f5f696e6372656d656e74",
|
||||||
|
] {
|
||||||
|
assert!(missing_default("int unsigned|NO|30|<NONE>", actual).is_none());
|
||||||
|
}
|
||||||
|
assert!(missing_default(
|
||||||
|
"timestamp|NO|43555252454e545f54494d455354414d50|44454641554c545f47454e455241544544",
|
||||||
|
"timestamp|NO|<NULL>|<NONE>"
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(missing_default("int|NO|zz|<NONE>", "int|NO|<NULL>|<NONE>").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_defaults_are_readable_and_not_conflated() {
|
||||||
|
assert_eq!(
|
||||||
|
describe_signature("int unsigned|NO|30|<NONE>"),
|
||||||
|
"type=int unsigned, nullable=NO, default=\"0\", extra=none"
|
||||||
|
);
|
||||||
|
assert!(describe_signature("int unsigned|NO|<NULL>|<NONE>").contains("default=no default"));
|
||||||
|
assert!(describe_signature("varchar(8)|YES||<NONE>").contains("default=\"\""));
|
||||||
|
assert_eq!(describe_signature("legacy"), "legacy");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_release_cannot_use_a_partial_or_empty_schema_contract() {
|
fn a_release_cannot_use_a_partial_or_empty_schema_contract() {
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// SQUID has its own import ledger, outside the signed package migration ledger.
|
||||||
|
/// Keep this read-only: a pending base import can contain player data and must not
|
||||||
|
/// be replayed or cleared merely because the normal schema contract passes.
|
||||||
|
pub(crate) fn database_problems(root: &Path, mode: crate::realms::Mode, query: &impl Fn(&str) -> crate::Result<String>) -> crate::Result<Vec<crate::schema_check::Problem>> {
|
||||||
|
let active = root.join("Core/configs/modules/playerbots.conf");
|
||||||
|
let config = if active.is_file() { active } else { root.join("Core/configs/modules/playerbots.conf.dist") };
|
||||||
|
let enabled = if config.is_file() {
|
||||||
|
let conf = crate::config::parser::ConfFile::parse_bytes(&std::fs::read(config)?)?;
|
||||||
|
!matches!(conf.get("AiPlayerbot.Enabled").unwrap_or("1").trim().trim_matches('"').to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off")
|
||||||
|
} else { false };
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
for schema in [mode.schema("characters")?, mode.schema("world")?, "acore_playerbots"] {
|
||||||
|
let tables: std::collections::BTreeSet<_> = query(&format!("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='{schema}';"))?.lines().map(str::to_owned).collect();
|
||||||
|
if tables.contains("coa_squid_pending") {
|
||||||
|
for pending in query(&format!("SELECT path FROM `{schema}`.coa_squid_pending;"))?.lines().filter(|path| !path.is_empty()) {
|
||||||
|
problems.push(crate::schema_check::Problem { database: schema.into(), table: "coa_squid_pending".into(), column: String::new(), detail: format!("SQUID base import was interrupted: {pending}. File repair and package SQL updates cannot complete this import. Restore a verified database backup from before the import; do not delete the pending record or replay base SQL.") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enabled && schema == mode.schema("characters")? {
|
||||||
|
for table in ["playerbots_names", "playerbots_guild_names", "playerbots_arena_team_names"] {
|
||||||
|
if !tables.contains(table) {
|
||||||
|
problems.push(crate::schema_check::Problem { database: schema.into(), table: table.into(), column: String::new(), detail: "Required SQUID character table is missing. A normal server start provisions it when no interrupted import is present; an interrupted import requires database recovery first.".into() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(problems)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
pub struct Release {
|
pub struct Release {
|
||||||
pub tag: Option<String>,
|
pub tag: Option<String>,
|
||||||
@@ -203,6 +232,33 @@ pub fn validate(field: &Field, value: &str) -> crate::Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn database_check_reports_pending_squid_imports_in_each_realm_without_writes() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for mode in [crate::realms::Mode::Coa, crate::realms::Mode::Wildcard] {
|
||||||
|
let problems = database_problems(root.path(), mode, &|sql| {
|
||||||
|
assert!(sql.starts_with("SELECT "));
|
||||||
|
if sql.contains("information_schema.TABLES") { Ok("coa_squid_pending".into()) }
|
||||||
|
else { Ok("playerbots/base/ai_playerbot_texts.sql".into()) }
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(problems.len(), 3);
|
||||||
|
assert_eq!(problems[0].database, mode.schema("characters").unwrap());
|
||||||
|
assert!(problems.iter().all(|p| p.detail.contains("interrupted")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabled_squid_requires_character_tables_but_disabled_squid_does_not() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let config = root.path().join("Core/configs/modules/playerbots.conf");
|
||||||
|
crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 1\n").unwrap();
|
||||||
|
let query = |_: &str| Ok(String::new());
|
||||||
|
assert_eq!(database_problems(root.path(), crate::realms::Mode::Wildcard, &query).unwrap().len(), 3);
|
||||||
|
crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 0\n").unwrap();
|
||||||
|
assert!(database_problems(root.path(), crate::realms::Mode::Wildcard, &query).unwrap().is_empty());
|
||||||
|
crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 1\n").unwrap();
|
||||||
|
assert!(database_problems(root.path(), crate::realms::Mode::Coa, &|_| Ok("playerbots_names\nplayerbots_guild_names\nplayerbots_arena_team_names".into())).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn a_setting_with_choices_only_accepts_one_of_them() {
|
fn a_setting_with_choices_only_accepts_one_of_them() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[
|
crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[
|
||||||
|
|||||||
+1003
-202
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
|||||||
|
use std::{collections::BTreeMap, fs, path::Path};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::{config::parser::ConfFile, fsx, Error, Result};
|
||||||
|
|
||||||
|
const FILES: &[&str] = &[
|
||||||
|
"Settings/repack.json", "Settings/realm-profile.json",
|
||||||
|
"Settings/worldserver.conf.template", "Settings/authserver.conf.template",
|
||||||
|
"Core/configs/worldserver.conf", "Core/configs/authserver.conf",
|
||||||
|
"Settings/realm-profiles/coa.json", "Settings/realm-profiles/wildcard.json",
|
||||||
|
".realms/secondary/Settings/repack.json", ".realms/secondary/Settings/realm-profile.json",
|
||||||
|
".realms/secondary/Settings/worldserver.conf.template", ".realms/secondary/Settings/authserver.conf.template",
|
||||||
|
".realms/secondary/Core/configs/worldserver.conf", ".realms/secondary/Core/configs/authserver.conf",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Saved { bytes: Option<Vec<u8>>, sha256: Option<String> }
|
||||||
|
|
||||||
|
pub(crate) fn pending(meta: &Path) -> bool { meta.join("validation-configs.json").exists() }
|
||||||
|
|
||||||
|
pub(crate) fn begin(root: &Path, meta: &Path) -> Result<()> {
|
||||||
|
if pending(meta) { return Err(Error::Invalid("Interrupted isolated validation must be recovered first.".into())); }
|
||||||
|
let mut saved = BTreeMap::new();
|
||||||
|
for rel in FILES {
|
||||||
|
let path = fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?;
|
||||||
|
let bytes = if path.exists() { Some(fs::read(path)?) } else { None };
|
||||||
|
let sha256 = bytes.as_ref().map(|b| fsx::sha256_bytes(b));
|
||||||
|
saved.insert(*rel, Saved { bytes, sha256 });
|
||||||
|
}
|
||||||
|
let mut settings: serde_json::Value = fsx::read_json(&root.join("Settings/repack.json"))?;
|
||||||
|
let mut realm: Option<serde_json::Value> = if root.join("Settings/realm-profile.json").exists() {
|
||||||
|
Some(fsx::read_json(&root.join("Settings/realm-profile.json"))?)
|
||||||
|
} else { None };
|
||||||
|
let mut excluded: std::collections::BTreeSet<u64> = ["mysqlPort", "authPort", "worldPort", "raPort"].iter()
|
||||||
|
.filter_map(|key| settings[*key].as_u64()).collect();
|
||||||
|
if let Some(state) = &realm {
|
||||||
|
excluded.extend(["secondary_world_port", "secondary_ra_port"].iter().filter_map(|key| state[*key].as_u64()));
|
||||||
|
}
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
let mut reserve = || -> Result<u16> {
|
||||||
|
loop {
|
||||||
|
let socket = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
let port = socket.local_addr()?.port();
|
||||||
|
if excluded.insert(u64::from(port)) {
|
||||||
|
sockets.push(socket);
|
||||||
|
return Ok(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for key in ["authPort", "worldPort", "raPort"] { settings[key] = reserve()?.into(); }
|
||||||
|
if let Some(state) = &mut realm {
|
||||||
|
for key in ["secondary_world_port", "secondary_ra_port"] {
|
||||||
|
state[key] = reserve()?.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut configs = Vec::new();
|
||||||
|
for rel in ["Settings/worldserver.conf.template", "Settings/authserver.conf.template", "Core/configs/worldserver.conf", "Core/configs/authserver.conf"] {
|
||||||
|
let path = root.join(rel);
|
||||||
|
if !path.exists() && rel.ends_with(".template") {
|
||||||
|
return Err(Error::Invalid(format!("Cannot isolate validation without {rel}.")));
|
||||||
|
}
|
||||||
|
if path.exists() {
|
||||||
|
let mut conf = ConfFile::parse_bytes(&fs::read(&path)?)?;
|
||||||
|
conf.set("BindIP", "\"127.0.0.1\"", &[]);
|
||||||
|
if rel.contains("worldserver") { conf.set("Ra.IP", "\"127.0.0.1\"", &[]); }
|
||||||
|
configs.push((path, conf.to_text()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fsx::atomic_write_json(&meta.join("validation-configs.json"), &saved)?;
|
||||||
|
fsx::atomic_write_json(&root.join("Settings/repack.json"), &settings)?;
|
||||||
|
if let Some(state) = realm { fsx::atomic_write_json(&root.join("Settings/realm-profile.json"), &state)?; }
|
||||||
|
for (path, text) in configs { fsx::atomic_write(&path, text.as_bytes())?; }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("server");
|
||||||
|
let meta = dir.path().join("metadata");
|
||||||
|
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||||
|
fs::create_dir_all(&meta).unwrap();
|
||||||
|
fsx::atomic_write_json(&root.join(FILES[0]), &serde_json::json!({"mysqlPort":3306,"authPort":3724,"worldPort":8085,"raPort":3443})).unwrap();
|
||||||
|
fsx::atomic_write_json(&root.join(FILES[1]), &serde_json::json!({"simultaneous":true,"secondary_world_port":8086,"secondary_ra_port":3444})).unwrap();
|
||||||
|
for rel in [FILES[2], FILES[3]] {
|
||||||
|
fsx::atomic_write(&root.join(rel), b"BindIP = \"0.0.0.0\"\r\nRa.IP = \"0.0.0.0\"\r\n").unwrap();
|
||||||
|
}
|
||||||
|
(dir, root, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interrupted_validation_restores_original_bytes_and_absent_files() {
|
||||||
|
let (_dir, root, meta) = fixture();
|
||||||
|
let original: Vec<_> = FILES.iter().map(|p| fs::read(root.join(p)).ok()).collect();
|
||||||
|
begin(&root, &meta).unwrap();
|
||||||
|
assert!(crate::update::ensure_recovered(&meta).is_err());
|
||||||
|
assert!(begin(&root, &meta).is_err());
|
||||||
|
let settings: serde_json::Value = fsx::read_json(&root.join(FILES[0])).unwrap();
|
||||||
|
assert_eq!(settings["mysqlPort"], 3306);
|
||||||
|
let realm: serde_json::Value = fsx::read_json(&root.join(FILES[1])).unwrap();
|
||||||
|
let ports: std::collections::BTreeSet<_> = [settings["authPort"].as_u64(), settings["worldPort"].as_u64(), settings["raPort"].as_u64(), realm["secondary_world_port"].as_u64(), realm["secondary_ra_port"].as_u64()].into_iter().collect();
|
||||||
|
assert_eq!(ports.len(), 5);
|
||||||
|
let config = ConfFile::parse_bytes(&fs::read(root.join(FILES[2])).unwrap()).unwrap();
|
||||||
|
assert_eq!(config.get("BindIP"), Some("\"127.0.0.1\""));
|
||||||
|
fsx::atomic_write(&root.join(FILES[4]), b"rendered temporary configuration").unwrap();
|
||||||
|
restore(&root, &meta).unwrap();
|
||||||
|
for (rel, bytes) in FILES.iter().zip(original) { assert_eq!(fs::read(root.join(rel)).ok(), bytes); }
|
||||||
|
assert!(!pending(&meta));
|
||||||
|
restore(&root, &meta).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damaged_recovery_copy_blocks_all_restoration() {
|
||||||
|
let (_dir, root, meta) = fixture();
|
||||||
|
begin(&root, &meta).unwrap();
|
||||||
|
let journal = meta.join("validation-configs.json");
|
||||||
|
let mut saved: BTreeMap<String, Saved> = fsx::read_json(&journal).unwrap();
|
||||||
|
saved.get_mut(FILES[0]).unwrap().bytes = Some(b"damaged".to_vec());
|
||||||
|
fsx::atomic_write_json(&journal, &saved).unwrap();
|
||||||
|
let before = fs::read(root.join(FILES[2])).unwrap();
|
||||||
|
assert!(restore(&root, &meta).is_err());
|
||||||
|
assert_eq!(fs::read(root.join(FILES[2])).unwrap(), before);
|
||||||
|
assert!(pending(&meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_template_fails_before_any_configuration_changes() {
|
||||||
|
let (_dir, root, meta) = fixture();
|
||||||
|
fs::remove_file(root.join(FILES[3])).unwrap();
|
||||||
|
let before = fs::read(root.join(FILES[0])).unwrap();
|
||||||
|
assert!(begin(&root, &meta).is_err());
|
||||||
|
assert!(!pending(&meta));
|
||||||
|
assert_eq!(fs::read(root.join(FILES[0])).unwrap(), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restore(root: &Path, meta: &Path) -> Result<()> {
|
||||||
|
if !pending(meta) { return Ok(()); }
|
||||||
|
let journal = meta.join("validation-configs.json");
|
||||||
|
let saved: BTreeMap<String, Saved> = fsx::read_json(&journal)?;
|
||||||
|
if saved.len() != FILES.len() || FILES.iter().any(|rel| !saved.contains_key(*rel)) {
|
||||||
|
return Err(Error::Invalid("Isolated validation configuration journal is incomplete.".into()));
|
||||||
|
}
|
||||||
|
for (rel, file) in &saved {
|
||||||
|
fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?;
|
||||||
|
if file.bytes.as_ref().map(|b| fsx::sha256_bytes(b)) != file.sha256 {
|
||||||
|
return Err(Error::Invalid(format!("Isolated validation recovery copy {rel} is damaged.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (rel, file) in saved {
|
||||||
|
let path = fsx::ensure_within(root, &fsx::safe_join(root, &rel)?)?;
|
||||||
|
match file.bytes {
|
||||||
|
Some(bytes) => fsx::atomic_write(&path, &bytes)?,
|
||||||
|
None if path.exists() => fs::remove_file(path)?,
|
||||||
|
None => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::remove_file(journal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
use crate::{manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Result};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Selection {
|
||||||
|
pub source: Source,
|
||||||
|
// Retained for the frontend response shape; direct updates never offer a bridge.
|
||||||
|
pub latest_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result<Selection> {
|
||||||
|
select_with(meta, source, &mut |source| pkgsource::fetch_manifest(source, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_with(meta: &InstallMeta, source: Source, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec<u8>)>) -> Result<Selection> {
|
||||||
|
let (manifest, _) = fetch(&source)?;
|
||||||
|
update::validate_candidate(meta, &manifest)?;
|
||||||
|
Ok(Selection { source, latest_version: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
use std::{fs, path::Path};
|
||||||
|
|
||||||
|
fn package() -> serde_json::Value {
|
||||||
|
serde_json::json!({"schema":1,"kind":"update","version":"0.261015.58","core":{"commit":"a".repeat(40)},
|
||||||
|
"builtAt":"test","minManagerVersion":"0.6.16","files":[],"migrations":[],"archive":null,
|
||||||
|
"compatibility":{"schema":1,"platform":if cfg!(windows) {"windows-x86_64"} else {"linux-x86_64"},
|
||||||
|
"coreCommit":"a".repeat(40),"moduleCommits":{"squid":"b".repeat(40)},"clientPatchVersion":"1.7",
|
||||||
|
"sourceVersions":["0.261013.54"],"sourceDatabases":{"0.261013.54":{"manifestSha256":"c".repeat(64),"schemaSha256":null}}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta(version: &str) -> InstallMeta {
|
||||||
|
let mut meta = InstallMeta::new(crate::registry::InstallKind::New, Path::new("unused"));
|
||||||
|
meta.core.version = Some(version.into());
|
||||||
|
meta
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(value: &serde_json::Value, tamper: bool) -> (tempfile::TempDir, String) {
|
||||||
|
let folder = tempfile::tempdir().unwrap();
|
||||||
|
let signing = SigningKey::from_bytes(&[37; 32]);
|
||||||
|
let bytes = serde_json::to_vec(value).unwrap();
|
||||||
|
fs::write(folder.path().join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap();
|
||||||
|
fs::write(folder.path().join("manifest.json"), if tamper { b"tampered".as_slice() } else { &bytes }).unwrap();
|
||||||
|
(folder, STANDARD.encode(signing.verifying_key().to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_known_older_version_selects_the_latest_signed_package_once() {
|
||||||
|
for installed in ["0.2.0", "0.261004.20", "0.261005.0", "0.261007.24", "0.261011.800006", "0.261013.54", "0.261015.58"] {
|
||||||
|
let (folder, key) = fixture(&package(), false);
|
||||||
|
for kind in [crate::registry::InstallKind::New, crate::registry::InstallKind::Imported] {
|
||||||
|
let mut installation = meta(installed);
|
||||||
|
installation.kind = kind;
|
||||||
|
let mut calls = 0;
|
||||||
|
let selected = select_with(&installation, Source::Dir(folder.path().into()), &mut |source| {
|
||||||
|
calls += 1;
|
||||||
|
pkgsource::fetch_manifest(source, &key)
|
||||||
|
}).unwrap();
|
||||||
|
assert!(matches!(selected.source, Source::Dir(path) if path == folder.path()));
|
||||||
|
assert!(selected.latest_version.is_none());
|
||||||
|
assert_eq!(calls, 1, "no intermediate package fetches for {installed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_versions_and_downgrades_remain_blocked() {
|
||||||
|
let (folder, key) = fixture(&package(), false);
|
||||||
|
for installed in ["unknown", "a65be06ec62f4", "0.261016.59"] {
|
||||||
|
assert!(select(&meta(installed), Source::Dir(folder.path().into()), &key).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_target_is_required_even_for_unlisted_old_versions() {
|
||||||
|
let (folder, key) = fixture(&package(), true);
|
||||||
|
assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_and_manager_requirements_are_still_enforced() {
|
||||||
|
for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] {
|
||||||
|
let mut target = package();
|
||||||
|
if field == "platform" { target["compatibility"][field] = value.into(); }
|
||||||
|
else { target[field] = value.into(); }
|
||||||
|
let (folder, key) = fixture(&target, false);
|
||||||
|
assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,61 @@ fn run() -> Result<(), String> {
|
|||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(DEFAULT_PART_SIZE);
|
.unwrap_or(DEFAULT_PART_SIZE);
|
||||||
match cmd.as_str() {
|
match cmd.as_str() {
|
||||||
|
"plan-update" => {
|
||||||
|
let mut meta = coa_core::registry::InstallMeta::new(coa_core::registry::InstallKind::New, std::path::Path::new("unused"));
|
||||||
|
meta.core.version = Some(need(&a, "from")?.clone());
|
||||||
|
let selection = coa_core::update_route::select(&meta, coa_core::pkgsource::Source::Url(need(&a, "source")?.clone()), coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?;
|
||||||
|
let (manifest, _) = coa_core::pkgsource::fetch_manifest(&selection.source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?;
|
||||||
|
println!("{}", serde_json::json!({"from":meta.core.version,"next":manifest.version,"latest":selection.latest_version}));
|
||||||
|
}
|
||||||
|
"verify-install" => {
|
||||||
|
coa_core::release_schema::verify_install_package(
|
||||||
|
&PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
).map_err(e)?;
|
||||||
|
}
|
||||||
|
"compose-install" => {
|
||||||
|
coa_core::release_schema::compose_install(
|
||||||
|
&PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "candidate")?),
|
||||||
|
&PathBuf::from(need(&a, "fixture")?), coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
).map_err(e)?;
|
||||||
|
}
|
||||||
|
"qualify-install" => {
|
||||||
|
let report = coa_core::release_schema::qualify_install(
|
||||||
|
&PathBuf::from(need(&a, "package")?), &PathBuf::from(need(&a, "fixture")?),
|
||||||
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
).map_err(e)?;
|
||||||
|
coa_core::fsx::atomic_write_json(&PathBuf::from(need(&a, "report")?), &report).map_err(e)?;
|
||||||
|
}
|
||||||
|
"qualify-upgrade" => {
|
||||||
|
let report = coa_core::release_schema::qualify_upgrade(
|
||||||
|
&PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "source")?),
|
||||||
|
&PathBuf::from(need(&a, "candidate")?), &PathBuf::from(need(&a, "fixture")?),
|
||||||
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
).map_err(e)?;
|
||||||
|
coa_core::fsx::atomic_write_json(&PathBuf::from(need(&a, "report")?), &report).map_err(e)?;
|
||||||
|
println!("Manager upgrade transaction passed isolated qualification");
|
||||||
|
}
|
||||||
|
"qualify-startup" => {
|
||||||
|
coa_core::release_schema::validate_startup(
|
||||||
|
&PathBuf::from(need(&a, "fixture")?), &PathBuf::from(need(&a, "tree")?),
|
||||||
|
&PathBuf::from(need(&a, "base")?), coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
).map_err(e)?;
|
||||||
|
println!("authserver and worldserver passed isolated startup qualification");
|
||||||
|
}
|
||||||
|
"verify-channel" => {
|
||||||
|
let bytes = std::fs::read(need(&a, "file")?).map_err(|x| x.to_string())?;
|
||||||
|
let pointer = coa_core::channels::verify(&bytes, need(&a, "channel")?, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?;
|
||||||
|
println!("{}", serde_json::to_string(&pointer).map_err(|x| x.to_string())?);
|
||||||
|
}
|
||||||
|
"verify-manifest" => {
|
||||||
|
let source = coa_core::pkgsource::Source::Dir(PathBuf::from(need(&a, "dir")?));
|
||||||
|
let (manifest, _) = coa_core::pkgsource::fetch_manifest(&source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?;
|
||||||
|
println!("verified signed manifest {}", manifest.version);
|
||||||
|
}
|
||||||
|
"verify-upgrade-fixture" => {
|
||||||
|
coa_core::release_schema::verify_upgrade_fixture(&PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?;
|
||||||
|
println!("verified signed upgrade fixture and every archive file");
|
||||||
|
}
|
||||||
"extract-schema-base" => {
|
"extract-schema-base" => {
|
||||||
coa_core::release_schema::extract_base(
|
coa_core::release_schema::extract_base(
|
||||||
&PathBuf::from(need(&a, "package")?),
|
&PathBuf::from(need(&a, "package")?),
|
||||||
@@ -195,6 +250,16 @@ fn run() -> Result<(), String> {
|
|||||||
.map_err(e)?;
|
.map_err(e)?;
|
||||||
println!("signature, archive contents and database schema contract OK");
|
println!("signature, archive contents and database schema contract OK");
|
||||||
}
|
}
|
||||||
|
"sign-channel" => {
|
||||||
|
let pointer = coa_core::channels::Pointer {
|
||||||
|
schema: 1, channel: need(&a, "channel")?.clone(),
|
||||||
|
version: need(&a, "version")?.clone(),
|
||||||
|
release_tag: format!("server-{}", need(&a, "version")?),
|
||||||
|
snapshot: need(&a, "snapshot")?.clone(),
|
||||||
|
};
|
||||||
|
let bytes = coa_core::channels::sign(&pointer, &signing_key()?).map_err(e)?;
|
||||||
|
coa_core::fsx::atomic_write(&PathBuf::from(need(&a, "out")?), &bytes).map_err(e)?;
|
||||||
|
}
|
||||||
other => return Err(format!("unknown command {other}")),
|
other => return Err(format!("unknown command {other}")),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -422,6 +422,18 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop,
|
|||||||
DivxTac.dll deadlocks the world loading screen under wine-mono. `COA_PROTONPATH`, `COA_WINEPREFIX` or
|
DivxTac.dll deadlocks the world loading screen under wine-mono. `COA_PROTONPATH`, `COA_WINEPREFIX` or
|
||||||
`client-launch.json` in the Manager's folder (`proton_path`, `prefix`, `runner`) override the detection. Whether the client
|
`client-launch.json` in the Manager's folder (`proton_path`, `prefix`, `runner`) override the detection. Whether the client
|
||||||
runs is read from `/proc` (under Wine the command line holds the Windows form of the path). Output goes to `client.log`.
|
runs is read from `/proc` (under Wine the command line holds the Windows form of the path). Output goes to `client.log`.
|
||||||
|
* **Sharing with friends** (`docker/sharing.rs`): who can reach the game is decided by the address the container ports are
|
||||||
|
published on, `bindAddress` in `docker.json`; the `BindIP` of the configuration files is forced to `0.0.0.0` inside the
|
||||||
|
containers and changes nothing. Opening and closing therefore edit `bindAddress` (and set `CoA.AllowRemoteClients`), "servers
|
||||||
|
open" and the exposure check are computed from it (the database is never published, the console only on `127.0.0.1`), and
|
||||||
|
there is no firewall row on Linux. Settings the backend fixes (`docker::FORCED_KEYS`) are not offered; a test keeps that list
|
||||||
|
equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come
|
||||||
|
up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the
|
||||||
|
user directories file, memory from `/proc/meminfo`, the distribution name for the problem report).
|
||||||
|
* **Game data folder** (`docker::game_data_dir` / `set_game_data`, Settings card): `dataDir` in `docker.json` can be changed
|
||||||
|
after the install, only while the world and auth are stopped, and only to a folder that passes the install checks. A world
|
||||||
|
that stops with "does not hold the CoA client dbc set" is reported as `GameDataMismatch` (wrong data set: the standard one,
|
||||||
|
not Conquest of Azeroth's) instead of a bare failure.
|
||||||
* **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the
|
* **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the
|
||||||
Wildcard realm profiles, and the client under Wine/Proton.
|
Wildcard realm profiles, and the client under Wine/Proton.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Stabilization evidence — 14 October 2026
|
||||||
|
|
||||||
|
This batch starts from Manager master b45e250 (published version 0.6.16).
|
||||||
|
Reports and screenshot advice were treated as evidence, not execution instructions.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Successful Repair previously committed file operations without setting `done`.
|
||||||
|
The next journal scan rejected its own completed transaction. A regression failed
|
||||||
|
on the original code with `Update journal contains inconsistent file operations`.
|
||||||
|
Repair now verifies target hashes and records completion before committing.
|
||||||
|
- Legacy completed repair journals are recognized only for the specific repair
|
||||||
|
shape, matching installation metadata and verified target hashes. Their original
|
||||||
|
journal is retained before atomically persisting completion. Interrupted,
|
||||||
|
mismatched, duplicate-path and other invalid journals continue to block actions.
|
||||||
|
Later legitimate updates do not invalidate the corrected historical record.
|
||||||
|
- Database Check and the final Repair report now include SQUID pending import
|
||||||
|
markers in characters, world and shared playerbots databases, plus missing
|
||||||
|
required character tables when SQUID is enabled. These are read-only checks;
|
||||||
|
partial imports are never cleared, dropped or automatically replayed.
|
||||||
|
- Private rehearsal reports file preparation/copy counts and database/startup
|
||||||
|
stages. Shutdown failure includes the installation path, service states, PID,
|
||||||
|
ports and redacted launcher output. Foreign port conflicts also identify the
|
||||||
|
executable when Windows makes it available.
|
||||||
|
|
||||||
|
## Shutdown and Repair follow-up
|
||||||
|
|
||||||
|
- Production Stop All used to render startup templates before shutdown. Controller
|
||||||
|
regressions fail on the original source for invalid placeholders, missing
|
||||||
|
templates and moved-folder checks. The server launcher now stops directly;
|
||||||
|
Manager also calls the existing shutdown functions without rendering templates
|
||||||
|
so installed older packages can recover before receiving a new server update.
|
||||||
|
- Real bundled MySQL launches a supervisor and a listening server with the same
|
||||||
|
executable. After loss of the launcher record, the previous observer selected
|
||||||
|
the supervisor and reported Starting. Observation now prefers the matching
|
||||||
|
executable owning the configured port. Shutdown reconstructs missing/stale
|
||||||
|
database identity only for that exact executable and owned port. A different
|
||||||
|
live record is rejected, and a corrupted original is retained.
|
||||||
|
- Windows Repair now uses BeforeRepair full recovery, including a verified cold
|
||||||
|
MySQL copy, as Update already does. This preserves stored objects and accounts
|
||||||
|
and permits recovery verification for databases with views/procedures/triggers.
|
||||||
|
- Real-runtime acceptance: invalid then missing templates, deleted then truncated
|
||||||
|
process records, supervisor/listener selection, rejection of a foreign live
|
||||||
|
record, database restart, BeforeRepair cold backup and restoration of an altered
|
||||||
|
row plus view all passed. Replaced database files remain available for recovery.
|
||||||
|
|
||||||
|
## Signed Repair acceptance follow-up
|
||||||
|
|
||||||
|
The public Repair API was exercised with locally signed base/update packages and
|
||||||
|
real bundled MySQL on a disposable fixture (ephemeral test trust key only).
|
||||||
|
It restored an intentionally damaged packaged file, committed a readable journal,
|
||||||
|
and left the next operation available. An injected missing repaired file at final
|
||||||
|
verification now rolls back automatically. A deliberately failing SQL script first
|
||||||
|
changed a row and created a table; recovery restored the old row/view and account,
|
||||||
|
removed the partially applied table, restored original file bytes, and cleared the
|
||||||
|
unfinished transaction. This full acceptance passed in 142.92 seconds.
|
||||||
|
|
||||||
|
Other post-snapshot failures now use the same rollback path. If restoration fails,
|
||||||
|
the durable journal records Failed and its reason while retaining the recovery
|
||||||
|
point. Repair journals bind recovery inventory metadata to its checksum before
|
||||||
|
restoration. Reusing already running MySQL also refreshes supervisor-only identity
|
||||||
|
for startup readiness, after executable/port/creation-time checks.
|
||||||
|
|
||||||
|
These are package/SQL/recovery tests on representative minimal database tables;
|
||||||
|
they do not claim successful client login, combat or an affected user's installation.
|
||||||
|
|
||||||
|
## Overview update failure at 2%
|
||||||
|
|
||||||
|
The additional screenshot reports a failed StopAll inside a private rehearsal,
|
||||||
|
with the launcher's moved-folder/changed-port guard, followed by a disappearing
|
||||||
|
progress bar on Overview. The shutdown adapter already bypasses startup preparation.
|
||||||
|
The native recovery test now also supplies a copied configuration signature with
|
||||||
|
an old installation path, verifies shutdown preserves it, and retains its database
|
||||||
|
row/view recovery assertions.
|
||||||
|
|
||||||
|
Overview previously rendered only its local start/stop failure, ignoring stored
|
||||||
|
update and recovery-state errors. It now displays those errors with the existing
|
||||||
|
technical-details and copy controls, even when the update offer and progress bar
|
||||||
|
are gone. A new update attempt clears an older local action error. Render tests
|
||||||
|
reproduced the missing alerts before the fix; all 12 frontend tests pass afterward.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Workspace tests, frontend build, frontend logic tests, translation validation,
|
||||||
|
changelog validation and whitespace checks are recorded in the accompanying
|
||||||
|
local logs and PR checks.
|
||||||
|
- Real bundled MySQL regression validates restored rows, users, procedures,
|
||||||
|
functions, views and triggers, and preservation of custom defaults. It also
|
||||||
|
confirms SQUID pending imports are reported without modifying their records.
|
||||||
|
- Published Content Scaling revision e5d8c675 passes the production-budget
|
||||||
|
regression for 40/100 HP, core multipliers, rank rates, groups and repeated
|
||||||
|
calculation. This is a code regression, not an in-game Wildcard/PTR comparison.
|
||||||
|
|
||||||
|
## Outstanding evidence and release gates
|
||||||
|
|
||||||
|
| Report | Current evidence | Next verification |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Manager #98 | Repair journal defect reproduced; report itself has no diagnostic ZIP | Reproduce the complete Custom Races workflow and verify existing affected journal recovery |
|
||||||
|
| Screenshot: SQUID Start fails but Repair reports zero | Separate SQUID ledger was absent from Repair checks | Retest with a copy of the affected database; recover from a verified pre-import backup |
|
||||||
|
| Manager #82 / screenshot lock messages | #90 and #91 are already in published 0.6.16; lock regressions pass | Distinguish an active slow check from abandoned maintenance using diagnostics |
|
||||||
|
| Manager #100 / 54% | Attached archive ends immediately after entering private rehearsal; no failure is captured | Measure the full private-copy run on the affected hardware; new progress identifies its stage |
|
||||||
|
| Screenshot: stopped-MySQL error | Broken-template shutdown and lost-record/supervisor failures reproduced and fixed on disposable real MySQL | Verify the affected installation has one of these causes; other failures remain diagnostic-specific |
|
||||||
|
| Manager #25 | Missing defaults have targeted repair; start-data incompatibilities remain separate | Retest imported schema and affected race/class data without suppressing incompatibility checks |
|
||||||
|
| Manager #54 | Schedule fix is published; Stop now tolerates broken templates and Repair has complete MySQL recovery | Verify the exact unresolved placeholder and original recovery transaction; automatic placeholder guesses are not introduced |
|
||||||
|
| Scaling screenshot | 2.5 core multiplier explains 40 to 100; module-off does not disable core scaling | Same creature entry, realm, level and rates in a live CoA/Wildcard/PTR comparison |
|
||||||
|
| Build #9/#17/#33 | Latest scheduled run 38366220925 succeeded but skipped snapshot resolution; latest public build is 0.261013.54 | Qualify a new integrated candidate; old issue state is not current release evidence |
|
||||||
|
|
||||||
|
This batch does not publish a Manager installer, promote a server package,
|
||||||
|
close user reports or claim the complete fresh-install/update/gameplay matrix.
|
||||||
|
|
||||||
|
Clean Windows installs now select the full image attached to the same signed stable release used by updates. They never fall back to the legacy base when the current full image is absent. Explicit offline/custom full packages remain supported, while Repair and release upgrade fixtures retain the legacy base source. The release worker composes and validates the complete current database image before publication; users do not run an intermediate base server or an update transaction.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Update workflow
|
||||||
|
|
||||||
|
The Manager selects the latest signed package directly for any known older server version. The source-version list records release qualification; it is not an exclusive allowlist and does not prove that a particular installed database can apply the package. Unknown imported versions, downgrades, incompatible platforms and newer Manager requirements remain blocked.
|
||||||
|
|
||||||
|
The updater verifies the signed manifest and archive, checks file changes and database history, stops the server, and saves one complete recovery point. It then replaces files, applies pending SQL once, verifies the target schema and starts the updated server once for validation. Shared authentication migrations run once; both realm histories are checked before changing either. Account and character identities are compared before and after migration and startup.
|
||||||
|
|
||||||
|
Account migration history is stored in the shared account database. Read-only checks recognize legacy auth records from both realms. Write operations adopt existing records and preserve already-applied core history; unknown legacy checksums stay unknown. History is read in two batched client calls, with explicit physical schemas and consistent text collations. Use the current Manager for subsequent database operations.
|
||||||
|
|
||||||
|
Applied migrations are skipped; changed checksums and interrupted/failed records stop the update. Duplicate SQL identifiers for the same schema are rejected before execution. SQL or startup failure restores files and databases together. If recovery fails, startup remains blocked and the recovery artifacts remain available.
|
||||||
|
|
||||||
|
The per-update nested server rehearsal and intermediate-package routing have been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed and no unexecuted SQL is marked applied. Retired source archives do not prevent a direct update, but the database history must still be consistent and target checks must pass.
|
||||||
|
|
||||||
|
## Verify with an actual installation copy
|
||||||
|
|
||||||
|
`cargo run -p coa-core --example upgrade_fixture -- <copy-root> <signed-package-directory>` exercises the production updater, including recovery copies and startup. It refuses mutation unless the directory name starts with `coa-schema-fixture-real` and contains `.coa-update-fixture`. Prepare a disposable copy from a verified recovery point, with separate ports, metadata and no client path. Never point it at a working installation.
|
||||||
|
|
||||||
|
`migration_plan <copy-root> <signed-manifest>` reads database history without creating a migration ledger. On a separate marked `update-database-diagnosis-*` copy, `--apply <migration-directory>` exercises the production database phase. `--sql-only <migration-directory>` verifies and applies the signed SQL list for both realms and compares account/character identities. SQL-only success is not full package/startup qualification and does not verify the target schema contract.
|
||||||
|
|
||||||
|
Record package signatures, exact source recovery point, actual result, repeat-run behavior and unavailable stages. Do not label a route universally tested based on a version list or SQL-only success.
|
||||||
+156
-31
@@ -256,14 +256,18 @@ async fn portable_launch(
|
|||||||
const DEFAULT_UPDATE_URL: &str =
|
const DEFAULT_UPDATE_URL: &str =
|
||||||
"https://github.com/Corfirean/coa-server-build/releases/download/stable";
|
"https://github.com/Corfirean/coa-server-build/releases/download/stable";
|
||||||
|
|
||||||
fn update_source(custom: Option<String>) -> Source {
|
fn update_source(custom: Option<String>) -> Result<Source> {
|
||||||
let pick = custom
|
let pick = custom
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.or_else(|| std::env::var("COA_UPDATE_SOURCE").ok());
|
.or_else(|| std::env::var("COA_UPDATE_SOURCE").ok());
|
||||||
match pick {
|
match pick {
|
||||||
Some(p) if p.to_ascii_lowercase().starts_with("http") => Source::Url(p),
|
Some(p) if p.to_ascii_lowercase().starts_with("http") => Ok(Source::Url(p)),
|
||||||
Some(p) => Source::Dir(PathBuf::from(p)),
|
Some(p) => Ok(Source::Dir(PathBuf::from(p))),
|
||||||
None => Source::Url(DEFAULT_UPDATE_URL.into()),
|
None => match coa_core::channels::resolve("stable") {
|
||||||
|
Ok(source) => Ok(source),
|
||||||
|
Err(Error::PackageNotPublished(_)) => Ok(Source::Url(DEFAULT_UPDATE_URL.into())),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +285,18 @@ fn package_source(custom: Option<String>) -> Source {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn installation_source(custom: Option<String>) -> Result<Source> {
|
||||||
|
let custom = custom.filter(|value| !value.trim().is_empty()).or_else(|| {
|
||||||
|
std::env::var("COA_PACKAGE_SOURCE")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
});
|
||||||
|
if custom.is_some() {
|
||||||
|
return Ok(package_source(custom));
|
||||||
|
}
|
||||||
|
Ok(Source::Installation(Box::new(update_source(None)?)))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ServerSummary {
|
struct ServerSummary {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -344,6 +360,44 @@ fn summary(id: String, path: PathBuf) -> ServerSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The game data folder of a Docker server; none for a repack, whose data is inside its own folder.
|
||||||
|
#[tauri::command]
|
||||||
|
fn game_data_folder(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> std::result::Result<Option<String>, UiError> {
|
||||||
|
let root = path_of(&state, &id)?;
|
||||||
|
if !coa_core::docker::is_docker(&root) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(
|
||||||
|
coa_core::docker::game_data_dir(&root)?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point a Docker server at another game data folder. The servers must be stopped.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_game_data_folder(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
path: String,
|
||||||
|
) -> std::result::Result<String, UiError> {
|
||||||
|
let root = path_of(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
|
blocking(move || {
|
||||||
|
if !coa_core::docker::is_docker(&root) {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"The game data of this server is inside its folder.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
coa_core::docker::set_game_data(&root, std::path::Path::new(&path))?;
|
||||||
|
Ok(path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn default_install_dir() -> String {
|
fn default_install_dir() -> String {
|
||||||
if platform::flavor() == Flavor::Docker {
|
if platform::flavor() == Flavor::Docker {
|
||||||
@@ -402,9 +456,15 @@ async fn add_server(
|
|||||||
meta.core.version = report.banner_revision.clone();
|
meta.core.version = report.banner_revision.clone();
|
||||||
meta.database.port = Some(report.ports.mysql);
|
meta.database.port = Some(report.ports.mysql);
|
||||||
meta.database.schemas = report.database_schemas.clone();
|
meta.database.schemas = report.database_schemas.clone();
|
||||||
|
// The executables are `worldserver.exe` in a repack and `worldserver` in a Docker server; the file check reads these names.
|
||||||
|
let exe_names = if coa_core::docker::is_docker(std::path::Path::new(&report.path)) {
|
||||||
|
("Core/worldserver", "Core/authserver")
|
||||||
|
} else {
|
||||||
|
("Core/worldserver.exe", "Core/authserver.exe")
|
||||||
|
};
|
||||||
for (rel, exe) in [
|
for (rel, exe) in [
|
||||||
("Core/worldserver.exe", &report.worldserver),
|
(exe_names.0, &report.worldserver),
|
||||||
("Core/authserver.exe", &report.authserver),
|
(exe_names.1, &report.authserver),
|
||||||
] {
|
] {
|
||||||
if let Some(e) = exe {
|
if let Some(e) = exe {
|
||||||
meta.original_hashes.insert(rel.into(), e.sha256.clone());
|
meta.original_hashes.insert(rel.into(), e.sha256.clone());
|
||||||
@@ -885,7 +945,7 @@ async fn install_requirements(
|
|||||||
}
|
}
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let (m, _) = coa_core::pkgsource::fetch_manifest(
|
let (m, _) = coa_core::pkgsource::fetch_manifest(
|
||||||
&package_source(package),
|
&installation_source(package)?,
|
||||||
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
)?;
|
)?;
|
||||||
let archive = m
|
let archive = m
|
||||||
@@ -916,10 +976,10 @@ async fn install_new(
|
|||||||
.install_cancel
|
.install_cancel
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone());
|
.map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone());
|
||||||
let source = package_source(package);
|
|
||||||
let registry = Registry::at(data_dir().join("installs.json"));
|
let registry = Registry::at(data_dir().join("installs.json"));
|
||||||
let dest_path = PathBuf::from(dest);
|
let dest_path = PathBuf::from(dest);
|
||||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let source = installation_source(package)?;
|
||||||
install::install_base(
|
install::install_base(
|
||||||
&install::Params {
|
&install::Params {
|
||||||
source,
|
source,
|
||||||
@@ -1133,7 +1193,7 @@ async fn repair_server(
|
|||||||
&root,
|
&root,
|
||||||
&dir,
|
&dir,
|
||||||
&package_source(None),
|
&package_source(None),
|
||||||
&update_source(None),
|
&update_source(None)?,
|
||||||
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
&|step, percent| {
|
&|step, percent| {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
@@ -1167,9 +1227,9 @@ async fn custom_races_status(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> std::result::Result<Option<coa_core::custom_races::ClientPatchStatus>, UiError> {
|
) -> std::result::Result<Option<coa_core::custom_races::ClientPatchStatus>, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let ctx = client_context(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let client = linked_client(&root)?;
|
let client = ctx.linked();
|
||||||
Ok(client
|
Ok(client
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(coa_core::custom_races::client_patch_status))
|
.map(coa_core::custom_races::client_patch_status))
|
||||||
@@ -1183,13 +1243,12 @@ async fn custom_races_install(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> std::result::Result<(), UiError> {
|
) -> std::result::Result<(), UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let ctx = client_context(&state, &id)?;
|
||||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
let cancel = begin_client_job(&state)?;
|
let cancel = begin_client_job(&state)?;
|
||||||
let sid = id.clone();
|
let sid = id.clone();
|
||||||
let result = blocking(move || {
|
let result = blocking(move || {
|
||||||
let client = linked_client(&root)?
|
let client = ctx.require()?;
|
||||||
.ok_or_else(|| Error::Invalid("No game client is linked to this server.".into()))?;
|
|
||||||
coa_core::custom_races::install_client_patches(&client, &cancel, &|step, percent| {
|
coa_core::custom_races::install_client_patches(&client, &cancel, &|step, percent| {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"custom-races-progress",
|
"custom-races-progress",
|
||||||
@@ -1200,9 +1259,11 @@ async fn custom_races_install(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
})?;
|
})?;
|
||||||
let enabled = coa_core::modules::list(&root)
|
let enabled = ctx.root.as_ref().map_or(true, |root| {
|
||||||
.iter()
|
coa_core::modules::list(root)
|
||||||
.any(|module| module.id == "custom-races" && module.enabled);
|
.iter()
|
||||||
|
.any(|module| module.id == "custom-races" && module.enabled)
|
||||||
|
});
|
||||||
coa_core::custom_races::set_client_patch_enabled(&client, enabled)
|
coa_core::custom_races::set_client_patch_enabled(&client, enabled)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -1210,6 +1271,16 @@ async fn custom_races_install(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn custom_races_uninstall(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> std::result::Result<(), UiError> {
|
||||||
|
let ctx = client_context(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
|
blocking(move || coa_core::custom_races::uninstall_client_patches(&ctx.require()?)).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn custom_races_toggle_client(
|
async fn custom_races_toggle_client(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -1361,7 +1432,7 @@ async fn check_update(
|
|||||||
background: Option<bool>,
|
background: Option<bool>,
|
||||||
) -> std::result::Result<update::Preview, UiError> {
|
) -> std::result::Result<update::Preview, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
let src = update_source(source);
|
|
||||||
// The check that repeats every few minutes must not start and stop the database of a stopped server.
|
// The check that repeats every few minutes must not start and stop the database of a stopped server.
|
||||||
let access = if background.unwrap_or(false) {
|
let access = if background.unwrap_or(false) {
|
||||||
update::DatabaseAccess::OnlyIfRunning
|
update::DatabaseAccess::OnlyIfRunning
|
||||||
@@ -1370,14 +1441,21 @@ async fn check_update(
|
|||||||
};
|
};
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let (_, meta) = install_meta(&root)?;
|
let (_, meta) = install_meta(&root)?;
|
||||||
update::preview_with(
|
let selection = coa_core::update_route::select(
|
||||||
|
&meta,
|
||||||
|
update_source(source)?,
|
||||||
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
)?;
|
||||||
|
let mut preview = update::preview_with(
|
||||||
&root,
|
&root,
|
||||||
&meta,
|
&meta,
|
||||||
&src,
|
&selection.source,
|
||||||
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
&Default::default(),
|
&Default::default(),
|
||||||
access,
|
access,
|
||||||
)
|
)?;
|
||||||
|
preview.latest_version = selection.latest_version;
|
||||||
|
Ok(preview)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -1400,10 +1478,19 @@ async fn apply_update(
|
|||||||
resolutions: BTreeMap<String, Resolution>,
|
resolutions: BTreeMap<String, Resolution>,
|
||||||
) -> std::result::Result<update::Outcome, UiError> {
|
) -> std::result::Result<update::Outcome, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
let src = update_source(source);
|
|
||||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let (dir, _) = install_meta(&root)?;
|
let (dir, meta) = install_meta(&root)?;
|
||||||
|
let src = coa_core::update_route::select(
|
||||||
|
&meta,
|
||||||
|
update_source(source)?,
|
||||||
|
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||||
|
)?
|
||||||
|
.source;
|
||||||
|
let (manifest, _) =
|
||||||
|
coa_core::pkgsource::fetch_manifest(&src, coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||||
|
update::validate_candidate(&meta, &manifest)?;
|
||||||
// Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button.
|
// Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button.
|
||||||
let observed = coa_core::process::observe(&root, &layout::read_ports(&root));
|
let observed = coa_core::process::observe(&root, &layout::read_ports(&root));
|
||||||
if observed.world.state != coa_core::process::ServiceState::Stopped
|
if observed.world.state != coa_core::process::ServiceState::Stopped
|
||||||
@@ -2229,7 +2316,7 @@ struct FriendsStatus {
|
|||||||
exposure: Vec<coa_core::net::Exposure>,
|
exposure: Vec<coa_core::net::Exposure>,
|
||||||
/// The configuration lets other computers reach the login and world servers.
|
/// The configuration lets other computers reach the login and world servers.
|
||||||
servers_open: bool,
|
servers_open: bool,
|
||||||
firewall: coa_core::firewall::Status,
|
firewall: Option<coa_core::firewall::Status>,
|
||||||
tailscale: coa_core::net::Tailscale,
|
tailscale: coa_core::net::Tailscale,
|
||||||
server_running: bool,
|
server_running: bool,
|
||||||
auth_port: u16,
|
auth_port: u16,
|
||||||
@@ -2284,7 +2371,23 @@ fn report_targets() -> Vec<coa_core::report::Target> {
|
|||||||
coa_core::report::targets()
|
coa_core::report::targets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The distribution on Linux ("Linux (Arch Linux)"), from `/etc/os-release`; empty if it cannot be read.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn windows_version() -> String {
|
||||||
|
std::fs::read_to_string("/etc/os-release")
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| {
|
||||||
|
t.lines().find_map(|l| {
|
||||||
|
l.strip_prefix("PRETTY_NAME=")
|
||||||
|
.map(|v| v.trim_matches('"').to_string())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|name| format!("Linux ({name})"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read.
|
/// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read.
|
||||||
|
#[cfg(windows)]
|
||||||
fn windows_version() -> String {
|
fn windows_version() -> String {
|
||||||
let mut cmd = std::process::Command::new("cmd.exe");
|
let mut cmd = std::process::Command::new("cmd.exe");
|
||||||
cmd.args(["/C", "ver"]);
|
cmd.args(["/C", "ver"]);
|
||||||
@@ -2366,9 +2469,14 @@ async fn friends_status(
|
|||||||
settings: coa_core::friends::load(&meta),
|
settings: coa_core::friends::load(&meta),
|
||||||
lan_ip: automatic.map(|a| a.to_string()),
|
lan_ip: automatic.map(|a| a.to_string()),
|
||||||
lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(),
|
lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(),
|
||||||
exposure: coa_core::net::exposure(&ports),
|
exposure: if coa_core::docker::is_docker(&root) {
|
||||||
|
coa_core::docker::exposure(&root, &ports)
|
||||||
|
} else {
|
||||||
|
coa_core::net::exposure(&ports)
|
||||||
|
},
|
||||||
servers_open: coa_core::friends::bind_is_open(&root),
|
servers_open: coa_core::friends::bind_is_open(&root),
|
||||||
firewall: coa_core::firewall::status(),
|
// The Windows firewall exists only on Windows; on Linux Docker publishes the ports itself.
|
||||||
|
firewall: (platform::flavor() == Flavor::Repack).then(coa_core::firewall::status),
|
||||||
tailscale: coa_core::net::tailscale(),
|
tailscale: coa_core::net::tailscale(),
|
||||||
server_running: coa_core::process::observe(&root, &ports).world.state
|
server_running: coa_core::process::observe(&root, &ports).world.state
|
||||||
== coa_core::process::ServiceState::Running,
|
== coa_core::process::ServiceState::Running,
|
||||||
@@ -2443,7 +2551,7 @@ async fn friends_enable(
|
|||||||
};
|
};
|
||||||
let open = mode != Mode::Local;
|
let open = mode != Mode::Local;
|
||||||
let changed = friends::set_open(&root, &meta, open)?;
|
let changed = friends::set_open(&root, &meta, open)?;
|
||||||
if open {
|
if open && platform::flavor() == Flavor::Repack {
|
||||||
coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?;
|
coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?;
|
||||||
}
|
}
|
||||||
if mode == Mode::Direct && use_upnp {
|
if mode == Mode::Direct && use_upnp {
|
||||||
@@ -2481,10 +2589,14 @@ async fn friends_package(
|
|||||||
let host = coa_core::friends::load(&meta)
|
let host = coa_core::friends::load(&meta)
|
||||||
.host
|
.host
|
||||||
.ok_or_else(|| Error::Invalid("Choose how friends connect first.".into()))?;
|
.ok_or_else(|| Error::Invalid("Choose how friends connect first.".into()))?;
|
||||||
let desktop = std::env::var_os("USERPROFILE")
|
let desktop = if platform::flavor() == Flavor::Docker {
|
||||||
.map(PathBuf::from)
|
coa_core::diag::desktop_or_temp()
|
||||||
.unwrap_or_else(std::env::temp_dir)
|
} else {
|
||||||
.join("Desktop");
|
std::env::var_os("USERPROFILE")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("Desktop")
|
||||||
|
};
|
||||||
let out = if desktop.is_dir() {
|
let out = if desktop.is_dir() {
|
||||||
desktop
|
desktop
|
||||||
} else {
|
} else {
|
||||||
@@ -2569,6 +2681,16 @@ async fn console_tail(
|
|||||||
) -> std::result::Result<Vec<coa_core::console::Line>, UiError> {
|
) -> std::result::Result<Vec<coa_core::console::Line>, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
|
// A Docker server's database writes to its container's output, not to a file.
|
||||||
|
if coa_core::docker::is_docker(&root)
|
||||||
|
&& matches!(source, coa_core::console::Source::Database)
|
||||||
|
{
|
||||||
|
return coa_core::docker::logs::database_log(
|
||||||
|
&root,
|
||||||
|
filter.as_deref(),
|
||||||
|
lines.unwrap_or(300).min(2000),
|
||||||
|
);
|
||||||
|
}
|
||||||
let path = coa_core::console::log_path(
|
let path = coa_core::console::log_path(
|
||||||
&root,
|
&root,
|
||||||
&data_dir().join("logs").join("manager.log"),
|
&data_dir().join("logs").join("manager.log"),
|
||||||
@@ -2630,6 +2752,8 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
default_install_dir,
|
default_install_dir,
|
||||||
|
game_data_folder,
|
||||||
|
set_game_data_folder,
|
||||||
install_environment,
|
install_environment,
|
||||||
scan_server,
|
scan_server,
|
||||||
add_server,
|
add_server,
|
||||||
@@ -2724,6 +2848,7 @@ pub fn run() {
|
|||||||
console_command,
|
console_command,
|
||||||
custom_races_status,
|
custom_races_status,
|
||||||
custom_races_install,
|
custom_races_install,
|
||||||
|
custom_races_uninstall,
|
||||||
custom_races_toggle_client,
|
custom_races_toggle_client,
|
||||||
portable_state,
|
portable_state,
|
||||||
portable_preflight,
|
portable_preflight,
|
||||||
|
|||||||
+3
-1
@@ -92,9 +92,11 @@ export {};
|
|||||||
if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;}
|
if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;}
|
||||||
if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip";
|
if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip";
|
||||||
if(cmd==="list_crashes") return [];
|
if(cmd==="list_crashes") return [];
|
||||||
|
if(cmd==="game_data_folder") return new URLSearchParams(location.search).get("flavor")==="docker"?"/srv/coa/server-data":null;
|
||||||
|
if(cmd==="set_game_data_folder") return args.path;
|
||||||
if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};}
|
if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};}
|
||||||
if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30};
|
if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30};
|
||||||
if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"};
|
if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.261014.1"};
|
||||||
if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;}
|
if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;}
|
||||||
if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;}
|
if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;}
|
||||||
if(cmd==="account_set_password") return null;
|
if(cmd==="account_set_password") return null;
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ const de: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"comp.saved": "Gespeichert. {n} Begleiter werden beim Serverstart erstellt.",
|
"comp.saved": "Gespeichert. {n} Begleiter werden beim Serverstart erstellt.",
|
||||||
|
|
||||||
// Client
|
// Client
|
||||||
|
"client.races.install": "Patch für benutzerdefinierte Völker installieren",
|
||||||
"client.title": "Spielclient",
|
"client.title": "Spielclient",
|
||||||
"client.looking": "Suche …",
|
"client.looking": "Suche …",
|
||||||
"client.intro": "Sag dem Manager, wo dein Spiel installiert ist, um einen SPIELEN-Button zu erhalten. Deine Spieldateien werden nie geändert, außer der Serveradresse und dem Begleiter-Addon.",
|
"client.intro": "Sag dem Manager, wo dein Spiel installiert ist, um einen SPIELEN-Button zu erhalten. Deine Spieldateien werden nie geändert, außer der Serveradresse und dem Begleiter-Addon.",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const en = {
|
|||||||
"status.attention": "Needs attention",
|
"status.attention": "Needs attention",
|
||||||
"status.running": "Running",
|
"status.running": "Running",
|
||||||
"status.stopped": "Stopped",
|
"status.stopped": "Stopped",
|
||||||
|
"status.crashed": "Stopped unexpectedly",
|
||||||
|
|
||||||
"btn.start": "START SERVER",
|
"btn.start": "START SERVER",
|
||||||
"btn.stop": "STOP SERVER",
|
"btn.stop": "STOP SERVER",
|
||||||
@@ -145,6 +146,8 @@ const en = {
|
|||||||
"err.operation_in_progress.message": "The server is busy starting or stopping. Wait a moment and try again.",
|
"err.operation_in_progress.message": "The server is busy starting or stopping. Wait a moment and try again.",
|
||||||
"err.startup_failed.title": "Server could not start",
|
"err.startup_failed.title": "Server could not start",
|
||||||
"err.startup_failed.message": "A service stopped while starting. Check the details for the cause.",
|
"err.startup_failed.message": "A service stopped while starting. Check the details for the cause.",
|
||||||
|
"err.game_data_mismatch.title": "The game data folder is not the CoA set",
|
||||||
|
"err.game_data_mismatch.message": "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.",
|
||||||
"err.docker_unavailable.title": "Docker is not available",
|
"err.docker_unavailable.title": "Docker is not available",
|
||||||
"err.docker_unavailable.message": "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.",
|
"err.docker_unavailable.message": "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.",
|
||||||
"err.hash_mismatch.title": "A downloaded file is damaged",
|
"err.hash_mismatch.title": "A downloaded file is damaged",
|
||||||
@@ -192,6 +195,11 @@ const en = {
|
|||||||
"install.localPkg": "Install from a local package folder instead of downloading",
|
"install.localPkg": "Install from a local package folder instead of downloading",
|
||||||
"install.localPkgHint": "(leave empty to download)",
|
"install.localPkgHint": "(leave empty to download)",
|
||||||
"install.button": "Install",
|
"install.button": "Install",
|
||||||
|
"gamedata.title": "Game data folder",
|
||||||
|
"gamedata.text": "The folder with the game's dbc, maps, vmaps and mmaps that this server reads. It is used where it is and never changed. It must be the Conquest of Azeroth set, not the standard one. Stop the server before changing it.",
|
||||||
|
"gamedata.change": "Change…",
|
||||||
|
"gamedata.dialog": "Choose the game data folder",
|
||||||
|
"gamedata.saved": "Saved. It is used the next time the server starts.",
|
||||||
"install.docker.title": "Docker is needed",
|
"install.docker.title": "Docker is needed",
|
||||||
"install.docker.text": "On Linux the server runs in Docker. Install Docker and start it, or allow your user to use it, then check again.",
|
"install.docker.text": "On Linux the server runs in Docker. Install Docker and start it, or allow your user to use it, then check again.",
|
||||||
"install.docker.retry": "Check again",
|
"install.docker.retry": "Check again",
|
||||||
@@ -240,6 +248,7 @@ const en = {
|
|||||||
"comp.saved": "Saved. {n} companions will be created when the server starts.",
|
"comp.saved": "Saved. {n} companions will be created when the server starts.",
|
||||||
|
|
||||||
// Client
|
// Client
|
||||||
|
"client.races.install": "Install custom race patch",
|
||||||
"client.title": "Game client",
|
"client.title": "Game client",
|
||||||
"client.looking": "Looking…",
|
"client.looking": "Looking…",
|
||||||
"client.intro": "Tell Manager where your game is installed to get a PLAY button. Your game files are never changed except the server address and the companion addon.",
|
"client.intro": "Tell Manager where your game is installed to get a PLAY button. Your game files are never changed except the server address and the companion addon.",
|
||||||
@@ -423,6 +432,7 @@ const en = {
|
|||||||
"upd.folderHint": "Update package folder (leave empty to use the official source)",
|
"upd.folderHint": "Update package folder (leave empty to use the official source)",
|
||||||
"upd.available": "Version {to} is available",
|
"upd.available": "Version {to} is available",
|
||||||
"upd.availableFrom": "Version {to} is available (you have {from})",
|
"upd.availableFrom": "Version {to} is available (you have {from})",
|
||||||
|
"upd.intermediate": "Update to {to} first, then check again to continue to {latest}. The package supports this version sequence; your database is checked when you update.",
|
||||||
"upd.summary": "Download {size} · {files} file(s) change",
|
"upd.summary": "Download {size} · {files} file(s) change",
|
||||||
"upd.summaryDb": "Download {size} · {files} file(s) change · {db} database update(s)",
|
"upd.summaryDb": "Download {size} · {files} file(s) change · {db} database update(s)",
|
||||||
"upd.conflict": "This file was modified outside CoA Server Manager.",
|
"upd.conflict": "This file was modified outside CoA Server Manager.",
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ const es: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"comp.saved": "Guardado. Se crearán {n} compañeros cuando se inicie el servidor.",
|
"comp.saved": "Guardado. Se crearán {n} compañeros cuando se inicie el servidor.",
|
||||||
|
|
||||||
// Client
|
// Client
|
||||||
|
"client.races.install": "Instalar parche de razas personalizadas",
|
||||||
"client.title": "Cliente del juego",
|
"client.title": "Cliente del juego",
|
||||||
"client.looking": "Buscando…",
|
"client.looking": "Buscando…",
|
||||||
"client.intro": "Indica al Manager dónde está instalado el juego para obtener un botón JUGAR. Tus archivos del juego nunca se modifican, salvo la dirección del servidor y el addon de compañeros.",
|
"client.intro": "Indica al Manager dónde está instalado el juego para obtener un botón JUGAR. Tus archivos del juego nunca se modifican, salvo la dirección del servidor y el addon de compañeros.",
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ const fr: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"comp.saved": "Enregistré. {n} compagnons seront créés au démarrage du serveur.",
|
"comp.saved": "Enregistré. {n} compagnons seront créés au démarrage du serveur.",
|
||||||
|
|
||||||
// Client
|
// Client
|
||||||
|
"client.races.install": "Installer le patch des races personnalisées",
|
||||||
"client.title": "Client du jeu",
|
"client.title": "Client du jeu",
|
||||||
"client.looking": "Recherche…",
|
"client.looking": "Recherche…",
|
||||||
"client.intro": "Indiquez au Manager où le jeu est installé pour obtenir un bouton JOUER. Vos fichiers de jeu ne sont jamais modifiés, sauf l’adresse du serveur et l’addon des compagnons.",
|
"client.intro": "Indiquez au Manager où le jeu est installé pour obtenir un bouton JOUER. Vos fichiers de jeu ne sont jamais modifiés, sauf l’adresse du serveur et l’addon des compagnons.",
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"comp.saved": "Сохранено. Компаньоны ({n}) будут созданы при запуске сервера.",
|
"comp.saved": "Сохранено. Компаньоны ({n}) будут созданы при запуске сервера.",
|
||||||
|
|
||||||
// Client
|
// Client
|
||||||
|
"client.races.install": "Установить патч кастомных рас",
|
||||||
"client.title": "Клиент игры",
|
"client.title": "Клиент игры",
|
||||||
"client.looking": "Ищу…",
|
"client.looking": "Ищу…",
|
||||||
"client.intro": "Укажите, где установлена игра, чтобы появилась кнопка «Играть». Файлы игры не изменяются, кроме адреса сервера и аддона компаньонов.",
|
"client.intro": "Укажите, где установлена игра, чтобы появилась кнопка «Играть». Файлы игры не изменяются, кроме адреса сервера и аддона компаньонов.",
|
||||||
@@ -413,6 +414,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)",
|
"upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)",
|
||||||
"upd.available": "Доступна версия {to}",
|
"upd.available": "Доступна версия {to}",
|
||||||
"upd.availableFrom": "Доступна версия {to} (у вас {from})",
|
"upd.availableFrom": "Доступна версия {to} (у вас {from})",
|
||||||
|
"upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Пакет поддерживает эту последовательность версий; состояние вашей базы проверяется при обновлении.",
|
||||||
"upd.summary": "Загрузка {size} · изменится файлов: {files}",
|
"upd.summary": "Загрузка {size} · изменится файлов: {files}",
|
||||||
"upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}",
|
"upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}",
|
||||||
"upd.conflict": "Этот файл был изменён вне CoA Server Manager.",
|
"upd.conflict": "Этот файл был изменён вне CoA Server Manager.",
|
||||||
|
|||||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user