mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Merge pull request #181 from Corfirean/fix/manager-092-issues
Manager 0.9.2: database startup, recovery and CoA settings
This commit is contained in:
22 files changed
+582
-83
No files matched your search
@@ -25,4 +25,6 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: node tools/check-i18n.mjs
|
||||
- run: npm run build
|
||||
- run: node --experimental-strip-types --test tools/*.test.mjs
|
||||
- run: python -B -m unittest discover -s tools -p test_launcher_bridge.py
|
||||
- run: cargo test --workspace --locked
|
||||
@@ -70,7 +70,9 @@ jobs:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- name: Tests
|
||||
run: cargo test --workspace -- --test-threads=1
|
||||
run: cargo test --workspace --locked -- --test-threads=1
|
||||
- run: node --experimental-strip-types --test tools/*.test.mjs
|
||||
- run: python -B -m unittest discover -s tools -p test_launcher_bridge.py
|
||||
- name: Build and sign (publish only when requested)
|
||||
id: tauri
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
|
||||
Generated
+3
-3
@@ -586,7 +586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-core"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -634,7 +634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-release"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"coa-core",
|
||||
@@ -643,7 +643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-server-manager"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"coa-control-proto",
|
||||
"coa-core",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto",
|
||||
exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Find and edit CoA options in the existing server settings
|
||||
---
|
||||
Search includes the separate CoA configuration, including legacy mount flight. Changes go to the correct file; realm profile controls stay managed by the Manager.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Recovery can stop an unfinished world startup after restoring an older launcher
|
||||
---
|
||||
If the world console is unavailable, stop only the verified recorded world process. Keep MySQL running if ownership or shutdown cannot be confirmed.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: New server installation waits for slower database startup
|
||||
---
|
||||
Allow up to five minutes for MySQL to become ready. Failed installations stop their temporary database before cleaning up its files.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Simultaneous update checks share results or wait their turn
|
||||
---
|
||||
Repeated checks no longer compete with another foreground or background check in the same Manager window. Maintenance and recovery locks remain enforced.
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Every setting of the world server's configuration, for people who want more than the curated list: the documented
|
||||
//! settings come from `worldserver.conf.dist` (the file that ships with the server and explains each one), the current
|
||||
//! settings come from `worldserver.conf.dist` and `modules/coa.conf.dist`, the current
|
||||
//! values from the configuration the next start will use. Anything that holds a password, a port, an address, a folder or
|
||||
//! the database connection is left out; the Manager owns those.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::Path;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::parser::ConfFile;
|
||||
use crate::config::{take_snapshot, targets, Scope};
|
||||
use crate::config::{take_snapshot, targets, Scope, Targets};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fsx;
|
||||
|
||||
@@ -39,6 +39,14 @@ fn hidden(key: &str) -> bool {
|
||||
|| k.starts_with("logindatabase.")
|
||||
|| k.starts_with("worlddatabase.")
|
||||
|| k.starts_with("characterdatabase.")
|
||||
|| [
|
||||
"coa.classmodel",
|
||||
"coa.realmtype",
|
||||
"coa.gamemodemask",
|
||||
"coa.mapclass10towarrior",
|
||||
"coa.clientbooleanconfigs",
|
||||
]
|
||||
.contains(&k.as_str())
|
||||
|| [
|
||||
"bindip",
|
||||
"realmid",
|
||||
@@ -121,52 +129,85 @@ fn dist_path(root: &Path) -> std::path::PathBuf {
|
||||
root.join("Core/configs/worldserver.conf.dist")
|
||||
}
|
||||
|
||||
fn sources(root: &Path) -> Result<Vec<(std::path::PathBuf, Targets)>> {
|
||||
let mut result = vec![(dist_path(root), targets(root, Scope::Server)?)];
|
||||
let dist = root.join("Core/configs/modules/coa.conf.dist");
|
||||
if dist.is_file() {
|
||||
let active = root.join("Core/configs/modules/coa.conf");
|
||||
result.push((
|
||||
dist,
|
||||
Targets {
|
||||
read: active.clone(),
|
||||
writes: vec![active],
|
||||
generated: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list(root: &Path) -> Result<Vec<Item>> {
|
||||
let dist = fs::read_to_string(dist_path(root)).map_err(|_| {
|
||||
Error::Invalid(
|
||||
"The documented settings file (worldserver.conf.dist) was not found on this server."
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let t = targets(root, Scope::Server)?;
|
||||
let active = ConfFile::parse_bytes(&fs::read(&t.read)?)?;
|
||||
Ok(parse_dist(&dist)
|
||||
.into_iter()
|
||||
.filter(|(k, _, _)| !hidden(k))
|
||||
.map(|(key, default, doc)| {
|
||||
let value = active
|
||||
.get(&key)
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or_else(|| default.clone());
|
||||
let changed = value != default;
|
||||
Item {
|
||||
key,
|
||||
value,
|
||||
default,
|
||||
doc,
|
||||
changed,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
let mut items = Vec::new();
|
||||
for (dist_path, t) in sources(root)? {
|
||||
let dist = fs::read_to_string(&dist_path)?;
|
||||
let active = ConfFile::parse_bytes(&if t.read.exists() {
|
||||
fs::read(&t.read)?
|
||||
} else {
|
||||
dist.as_bytes().to_vec()
|
||||
})?;
|
||||
items.extend(
|
||||
parse_dist(&dist)
|
||||
.into_iter()
|
||||
.filter(|(k, _, _)| !hidden(k))
|
||||
.map(|(key, default, doc)| {
|
||||
let value = active
|
||||
.get(&key)
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or_else(|| default.clone());
|
||||
let changed = value != default;
|
||||
Item {
|
||||
key,
|
||||
value,
|
||||
default,
|
||||
doc,
|
||||
changed,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Change settings that exist in the documented file. A value is one line of at most 500 characters. Returns the keys
|
||||
/// that actually changed; the previous files are snapshotted first (and can be restored from Settings).
|
||||
pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Result<Vec<String>> {
|
||||
let known: BTreeMap<String, String> =
|
||||
parse_dist(&fs::read_to_string(dist_path(root)).map_err(|_| {
|
||||
Error::Invalid("worldserver.conf.dist was not found on this server.".into())
|
||||
})?)
|
||||
.into_iter()
|
||||
.map(|(k, d, _)| (k, d))
|
||||
.collect();
|
||||
let t = targets(root, Scope::Server)?;
|
||||
let _lock = crate::update::operation_lock(meta)?;
|
||||
crate::update::ensure_recovered(meta)?;
|
||||
let sources = sources(root)?;
|
||||
let mut known = BTreeMap::new();
|
||||
let mut originals: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
|
||||
let mut confs: Vec<ConfFile> = Vec::new();
|
||||
for p in &t.writes {
|
||||
let bytes = fs::read(p)?;
|
||||
confs.push(ConfFile::parse_bytes(&bytes)?);
|
||||
originals.push((p.clone(), bytes));
|
||||
let mut owners = Vec::new();
|
||||
for (index, (dist, t)) in sources.iter().enumerate() {
|
||||
let defaults = fs::read(dist)?;
|
||||
for (key, default, _) in parse_dist(&String::from_utf8_lossy(&defaults)) {
|
||||
if known.insert(key.clone(), (default, index)).is_some() {
|
||||
return Err(Error::Invalid(format!(
|
||||
"{key} is documented in more than one configuration file."
|
||||
)));
|
||||
}
|
||||
}
|
||||
for p in &t.writes {
|
||||
let bytes = if p.exists() {
|
||||
fs::read(p)?
|
||||
} else {
|
||||
defaults.clone()
|
||||
};
|
||||
confs.push(ConfFile::parse_bytes(&bytes)?);
|
||||
originals.push((p.clone(), bytes));
|
||||
owners.push(index);
|
||||
}
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
for (key, value) in changes {
|
||||
@@ -181,10 +222,22 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Res
|
||||
"The value of {key} must be one line of text."
|
||||
)));
|
||||
}
|
||||
let same = confs.iter().all(|c| match c.get(key) {
|
||||
Some(v) => v.trim() == value,
|
||||
None => known[key] == value,
|
||||
});
|
||||
let (default, owner) = &known[key];
|
||||
if default.parse::<f64>().is_ok() && !value.parse::<f64>().is_ok_and(f64::is_finite)
|
||||
|| key == "CoA.LegacyMountFlight" && !matches!(value, "0" | "1")
|
||||
{
|
||||
return Err(Error::Invalid(format!(
|
||||
"The value of {key} must be a number matching its documented default."
|
||||
)));
|
||||
}
|
||||
let same = confs
|
||||
.iter()
|
||||
.zip(&owners)
|
||||
.filter(|(_, index)| *index == owner)
|
||||
.all(|(c, _)| match c.get(key) {
|
||||
Some(v) => v.trim() == value,
|
||||
None => default == value,
|
||||
});
|
||||
if !same {
|
||||
changed.push((key.clone(), value.to_string()));
|
||||
}
|
||||
@@ -192,20 +245,31 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Res
|
||||
if changed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
for c in &mut confs {
|
||||
for (c, owner) in confs.iter_mut().zip(&owners) {
|
||||
for (k, v) in &changed {
|
||||
c.set(k, v, &["Added by CoA Server Manager"]);
|
||||
if known[k].1 == *owner {
|
||||
c.set(k, v, &["Added by CoA Server Manager"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let texts: Vec<String> = confs.iter().map(ConfFile::to_text).collect();
|
||||
for text in &texts {
|
||||
for (text, owner) in texts.iter().zip(&owners) {
|
||||
let reread = ConfFile::parse(text);
|
||||
for (k, v) in &changed {
|
||||
if reread.get(k).map(str::trim) != Some(v.as_str()) {
|
||||
if known[k].1 == *owner && reread.get(k).map(str::trim) != Some(v.as_str()) {
|
||||
return Err(Error::Invalid(format!("internal check failed for {k}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let affected: std::collections::BTreeSet<_> =
|
||||
changed.iter().map(|(key, _)| known[key].1).collect();
|
||||
let (originals, texts): (Vec<_>, Vec<_>) = originals
|
||||
.into_iter()
|
||||
.zip(texts)
|
||||
.zip(owners)
|
||||
.filter(|(_, owner)| affected.contains(owner))
|
||||
.map(|(pair, _)| pair)
|
||||
.unzip();
|
||||
take_snapshot(
|
||||
meta,
|
||||
Scope::Server,
|
||||
@@ -232,6 +296,114 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Res
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unrelated_saves_do_not_create_coa_files_and_existing_realm_values_are_preserved() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (root, meta) = server(d.path());
|
||||
let module = root.join("Core/configs/modules");
|
||||
fs::create_dir_all(&module).unwrap();
|
||||
fs::write(
|
||||
module.join("coa.conf.dist"),
|
||||
"CoA.LegacyMountFlight = 0\nCoA.ClassModel = \"coa\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("Rate.Drop.Item.Rare".into(), "1.5".into())]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!module.join("coa.conf").exists());
|
||||
fs::write(
|
||||
module.join("coa.conf"),
|
||||
"# Reborn realm\nCoA.ClassModel = \"wcr\"\nCustom.Preserved = 42\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("CoA.ClassModel".into(), "\"coa\"".into())])
|
||||
)
|
||||
.is_err());
|
||||
save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("CoA.LegacyMountFlight".into(), "1".into())]),
|
||||
)
|
||||
.unwrap();
|
||||
let actual = fs::read_to_string(module.join("coa.conf")).unwrap();
|
||||
assert!(
|
||||
actual.contains("CoA.ClassModel = \"wcr\"") && actual.contains("Custom.Preserved = 42")
|
||||
);
|
||||
assert!(actual.contains("# Reborn realm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coa_settings_are_found_saved_to_the_module_and_snapshotted_without_profile_overrides() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (root, meta) = server(d.path());
|
||||
let module = root.join("Core/configs/modules");
|
||||
fs::create_dir_all(&module).unwrap();
|
||||
let defaults = "[worldserver]\n# Mount flight rules\nCoA.LegacyMountFlight = 0\nCoA.ClassModel = \"coa\"\nCoA.RealmType = \"live\"\n";
|
||||
fs::write(module.join("coa.conf.dist"), defaults).unwrap();
|
||||
let original_world = fs::read(root.join("Core/configs/worldserver.conf")).unwrap();
|
||||
let items = list(&root).unwrap();
|
||||
assert!(items
|
||||
.iter()
|
||||
.any(|i| i.key == "CoA.LegacyMountFlight" && i.value == "0"));
|
||||
assert!(!items
|
||||
.iter()
|
||||
.any(|i| i.key == "CoA.ClassModel" || i.key == "CoA.RealmType"));
|
||||
for value in ["2", "invalid", "1\nInjected = 1"] {
|
||||
assert!(save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("CoA.LegacyMountFlight".into(), value.into())])
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
assert!(!module.join("coa.conf").exists());
|
||||
save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([
|
||||
("CoA.LegacyMountFlight".into(), "1".into()),
|
||||
("Rate.Drop.Item.Rare".into(), "1.5".into()),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(fs::read_to_string(module.join("coa.conf"))
|
||||
.unwrap()
|
||||
.contains("CoA.LegacyMountFlight = 1"));
|
||||
let world = fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap();
|
||||
assert!(!world.contains("CoA.LegacyMountFlight"));
|
||||
assert!(world.contains("Rate.Drop.Item.Rare = 1.5"));
|
||||
assert!(!String::from_utf8(original_world)
|
||||
.unwrap()
|
||||
.contains("CoA.LegacyMountFlight"));
|
||||
assert_eq!(
|
||||
list(&root)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i.key == "CoA.LegacyMountFlight")
|
||||
.unwrap()
|
||||
.value,
|
||||
"1"
|
||||
);
|
||||
assert!(!crate::config::list_snapshots(&meta).is_empty());
|
||||
let saved = crate::config::list_snapshots(&meta).remove(0);
|
||||
crate::config::restore_snapshot(&meta, &saved.id).unwrap();
|
||||
assert_eq!(
|
||||
list(&root)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i.key == "CoA.LegacyMountFlight")
|
||||
.unwrap()
|
||||
.value,
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
const DIST: &str = "[worldserver]\r\n\r\n###################\r\n# SECTION\r\n###################\r\n\r\n#\r\n# Rate.Drop.Item.Epic\r\n# Rate.Drop.Item.Rare\r\n# Description: Drop rates by quality.\r\n# Default: 1\r\n#\r\n\r\nRate.Drop.Item.Rare = 1\r\nRate.Drop.Item.Epic = 1\r\n\r\n#\r\n# LoginDatabaseInfo\r\n# Description: secret\r\n#\r\n\r\nLoginDatabaseInfo = \"127.0.0.1;3306;a;b;c\"\r\n\r\n#\r\n# Instance.ResetTimeHour\r\n# Description: Hour of the reset.\r\n# Important: Use 0-23.\r\n# Default: 4\r\n#\r\n\r\nInstance.ResetTimeHour = 4\r\n";
|
||||
|
||||
fn server(d: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
|
||||
@@ -34,8 +34,8 @@ impl Verb {
|
||||
fn timeout(self, root: &Path) -> Duration {
|
||||
match self {
|
||||
Verb::StartAll | Verb::StartWorld if squid_enabled(root) => Duration::from_secs(2700),
|
||||
Verb::StartAll | Verb::StartWorld => Duration::from_secs(420),
|
||||
Verb::StartMysql => Duration::from_secs(120),
|
||||
Verb::StartAll | Verb::StartWorld => Duration::from_secs(720),
|
||||
Verb::StartMysql => Duration::from_secs(360),
|
||||
Verb::StopAll => Duration::from_secs(240),
|
||||
}
|
||||
}
|
||||
@@ -393,7 +393,7 @@ fn recover_mysql_launcher_state(root: &Path, prefer_listener: bool) -> Result<()
|
||||
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__')";
|
||||
const LAUNCH_SCRIPT: &str = include_str!("launcher_bridge.py");
|
||||
|
||||
pub(crate) fn patch_launcher_imports(text: &str) -> Result<String> {
|
||||
let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))";
|
||||
@@ -624,7 +624,11 @@ mod tests {
|
||||
std::fs::write(modules.join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap();
|
||||
assert_eq!(
|
||||
Verb::StartWorld.timeout(folder.path()),
|
||||
Duration::from_secs(420)
|
||||
Duration::from_secs(720)
|
||||
);
|
||||
assert_eq!(
|
||||
Verb::StartMysql.timeout(folder.path()),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -239,14 +239,14 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> {
|
||||
}
|
||||
fs::copy(&boot, &target)?; // launcher needs database.json to render its files at first start
|
||||
|
||||
let started = driver::run(root, Verb::StartMysql)?;
|
||||
if !started.ok {
|
||||
return Err(Error::Invalid(format!(
|
||||
"The database could not be started. {}",
|
||||
driver::startup_failure(root, &started)
|
||||
)));
|
||||
}
|
||||
let result = (|| -> Result<()> {
|
||||
let started = driver::run(root, Verb::StartMysql)?;
|
||||
if !started.ok {
|
||||
return Err(Error::Invalid(format!(
|
||||
"The database could not be started. {}",
|
||||
driver::startup_failure(root, &started)
|
||||
)));
|
||||
}
|
||||
let db = Db::from_repack(root, Account::Admin)?;
|
||||
let (root_pw, app_pw) = (random_hex(48), random_hex(48));
|
||||
let accounts = db.query(
|
||||
@@ -362,6 +362,7 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result<Installed> {
|
||||
// 3. Extract into our own staging folder.
|
||||
if staging_root.exists() {
|
||||
if staging_root.join(MARKER).is_file() {
|
||||
stop_installation(&staging_root)?;
|
||||
fs::remove_dir_all(&staging_root)?; // leftover of an earlier failed attempt that we created
|
||||
} else {
|
||||
return Err(Error::Invalid(format!("{} is left over from an earlier installation that did not finish, and the Manager cannot tell whether it may be deleted. Delete that folder yourself, then try again.", staging_root.display())));
|
||||
@@ -426,24 +427,60 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result<Installed> {
|
||||
})();
|
||||
|
||||
if result.is_err() {
|
||||
if staging_root.join(MARKER).exists() {
|
||||
// Unfinished work of ours; the destination was never touched.
|
||||
let _ = driver::run(&staging_root, Verb::StopAll);
|
||||
let _ = fs::remove_dir_all(&staging_root);
|
||||
let unfinished = if staging_root.join(MARKER).is_file() {
|
||||
Some(&staging_root)
|
||||
} else if dest.join(MARKER).is_file() {
|
||||
// Failed after the folder was moved into place. The destination was empty or absent before (preflight),
|
||||
// so what carries our marker is only our unfinished install.
|
||||
let _ = driver::run(&dest, Verb::StopAll);
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
Some(&dest)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(unfinished) = unfinished {
|
||||
if let Err(cleanup) = stop_installation(unfinished) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"{} {cleanup}",
|
||||
result.as_ref().unwrap_err()
|
||||
)));
|
||||
}
|
||||
let _ = fs::remove_dir_all(unfinished);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn stop_installation(root: &Path) -> Result<()> {
|
||||
if !root.join("Scripts/manage.py").is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let stopped = driver::run(root, Verb::StopAll)?;
|
||||
if !stopped.ok {
|
||||
return Err(Error::Invalid(format!(
|
||||
"The unfinished installation could not be stopped; its files were kept. {}",
|
||||
driver::startup_failure(root, &stopped)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_unstoppable_partial_installation_keeps_its_files_for_recovery() {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let root = folder.path();
|
||||
fs::create_dir_all(root.join("Scripts")).unwrap();
|
||||
fs::write(root.join("Scripts/manage.py"), "# incomplete launcher").unwrap();
|
||||
fs::write(root.join(MARKER), "").unwrap();
|
||||
fs::write(root.join("database-preserved"), "unchanged").unwrap();
|
||||
assert!(stop_installation(root).is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("database-preserved")).unwrap(),
|
||||
"unchanged"
|
||||
);
|
||||
assert!(root.join(MARKER).is_file());
|
||||
}
|
||||
|
||||
fn reg(d: &Path) -> Registry {
|
||||
Registry::at(d.join("reg/installs.json"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script = sys.argv[1]
|
||||
sys.path.insert(0, str(Path(script).resolve().parent))
|
||||
sys.argv = sys.argv[1:]
|
||||
launcher = runpy.run_path(script, run_name="coa_manager_launcher")
|
||||
original_wait = launcher["wait_ready"]
|
||||
|
||||
|
||||
def wait_ready(name, port, seconds=180):
|
||||
return original_wait(name, port, max(seconds, 300) if name == "mysql" else seconds)
|
||||
|
||||
|
||||
launcher["start_mysql"].__globals__["wait_ready"] = wait_ready
|
||||
if sys.argv[1] == "stop-all":
|
||||
shutdown = launcher["stop_all"].__globals__
|
||||
if "ra" in shutdown:
|
||||
original_ra = shutdown["ra"]
|
||||
|
||||
class UnsafeShutdown(Exception):
|
||||
pass
|
||||
|
||||
def shutdown_command(command, config):
|
||||
try:
|
||||
return original_ra(command, config)
|
||||
except (OSError, RuntimeError):
|
||||
if not command.startswith("server shutdown "):
|
||||
raise
|
||||
world = shutdown["process"]("world")
|
||||
if not world:
|
||||
return "Worldserver has already stopped."
|
||||
expected = Path(script).resolve().parents[1] / "Core" / "worldserver.exe"
|
||||
if Path(world.get("exe", "")) != expected or not shutdown["same_process"](world):
|
||||
raise UnsafeShutdown("World process ownership could not be verified; the database was left running.")
|
||||
try:
|
||||
shutdown["terminate"](world)
|
||||
except (OSError, RuntimeError) as error:
|
||||
raise UnsafeShutdown("Worldserver could not be stopped safely; its database was left running.") from error
|
||||
if not shutdown["wait_stopped"](world, 90):
|
||||
raise UnsafeShutdown("Worldserver is still stopping; its database was left running.")
|
||||
return "Stopped this server's recorded world process without its unavailable console."
|
||||
|
||||
shutdown["ra"] = shutdown_command
|
||||
config = launcher["settings"]()
|
||||
with launcher["control_lock"]():
|
||||
launcher["stop_all"](config)
|
||||
else:
|
||||
launcher["main"]()
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "coa-server-manager",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "coa-server-manager",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "coa-server-manager",
|
||||
"private": true,
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CoA Server Manager",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"identifier": "com.coa.servermanager",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
{
|
||||
"manager": {
|
||||
"ru": {
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Старые неподдерживаемые записи персонажей больше не блокируют несвязанные обновления сервера"
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Старые неподдерживаемые записи персонажей больше не блокируют несвязанные обновления сервера",
|
||||
"New server installation waits for slower database startup": "Установка нового сервера дольше ожидает запуска базы на медленных дисках",
|
||||
"Find and edit CoA options in the existing server settings": "Параметры CoA доступны для поиска и редактирования в настройках сервера",
|
||||
"Simultaneous update checks share results or wait their turn": "Одновременные проверки обновлений используют общий результат или ожидают своей очереди",
|
||||
"Recovery can stop an unfinished world startup after restoring an older launcher": "Восстановление может остановить зависший запуск мира после возврата старого launcher"
|
||||
},
|
||||
"de": {
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Bestehende nicht unterstützte Charakterdatensätze blockieren keine unabhängigen Serverupdates mehr"
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Bestehende nicht unterstützte Charakterdatensätze blockieren keine unabhängigen Serverupdates mehr",
|
||||
"New server installation waits for slower database startup": "Die Neuinstallation wartet länger auf einen langsamen Datenbankstart",
|
||||
"Find and edit CoA options in the existing server settings": "CoA-Optionen in den bestehenden Servereinstellungen suchen und bearbeiten",
|
||||
"Simultaneous update checks share results or wait their turn": "Gleichzeitige Updateprüfungen teilen Ergebnisse oder warten aufeinander",
|
||||
"Recovery can stop an unfinished world startup after restoring an older launcher": "Die Wiederherstellung stoppt einen unvollständigen Weltstart auch mit altem Launcher"
|
||||
},
|
||||
"fr": {
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Les anciennes fiches de personnage non prises en charge ne bloquent plus les mises à jour sans rapport"
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Les anciennes fiches de personnage non prises en charge ne bloquent plus les mises à jour sans rapport",
|
||||
"New server installation waits for slower database startup": "L’installation attend le démarrage de la base sur les disques plus lents",
|
||||
"Find and edit CoA options in the existing server settings": "Recherchez et modifiez les options CoA dans les paramètres du serveur",
|
||||
"Simultaneous update checks share results or wait their turn": "Les vérifications simultanées partagent leurs résultats ou attendent leur tour",
|
||||
"Recovery can stop an unfinished world startup after restoring an older launcher": "La restauration peut arrêter un démarrage du monde inachevé avec un ancien lanceur"
|
||||
},
|
||||
"es": {
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Los registros antiguos de personajes no compatibles ya no bloquean actualizaciones del servidor no relacionadas"
|
||||
"Existing unsupported character records no longer block unrelated server updates": "Los registros antiguos de personajes no compatibles ya no bloquean actualizaciones del servidor no relacionadas",
|
||||
"New server installation waits for slower database startup": "La instalación espera más tiempo al inicio de la base de datos en discos lentos",
|
||||
"Find and edit CoA options in the existing server settings": "Busca y edita opciones de CoA en la configuración actual del servidor",
|
||||
"Simultaneous update checks share results or wait their turn": "Las comprobaciones simultáneas comparten resultados o esperan su turno",
|
||||
"Recovery can stop an unfinished world startup after restoring an older launcher": "La recuperación puede detener el inicio del mundo tras restaurar un lanzador antiguo"
|
||||
},
|
||||
"zh": {
|
||||
"Existing unsupported character records no longer block unrelated server updates": "已有的不受支持角色记录不再阻止无关的服务器更新"
|
||||
"Existing unsupported character records no longer block unrelated server updates": "已有的不受支持角色记录不再阻止无关的服务器更新",
|
||||
"New server installation waits for slower database startup": "安装新服务器时会等待较慢的数据库启动",
|
||||
"Find and edit CoA options in the existing server settings": "在现有服务器设置中查找和编辑 CoA 选项",
|
||||
"Simultaneous update checks share results or wait their turn": "同时进行的更新检查会共用结果或依次执行",
|
||||
"Recovery can stop an unfinished world startup after restoring an older launcher": "恢复旧版启动器后,恢复流程也能停止未完成启动的世界服务器"
|
||||
}
|
||||
},
|
||||
"server": {}
|
||||
|
||||
+7
-1
@@ -1,4 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { updateCheckQueue } from "./updateCheckQueue";
|
||||
|
||||
const queuedUpdateCheck = updateCheckQueue<UpdatePreview>();
|
||||
|
||||
export const REMOTE_CLIENT_ID = "@remote-client";
|
||||
export interface RemoteConnection { host: string; client_path: string | null }
|
||||
@@ -539,7 +542,10 @@ export const api = {
|
||||
createAccount: (id: string, username: string, password: string, administrator: boolean) =>
|
||||
invoke<void>("create_account", { id, username, password, administrator }),
|
||||
/** `background`: the repeating check, which must not start the database of a stopped server. */
|
||||
checkUpdate: (id: string, source?: string, background?: boolean) => invoke<UpdatePreview>("check_update", { id, source: source ?? null, background: background ?? false }),
|
||||
checkUpdate: (id: string, source?: string, background?: boolean) => queuedUpdateCheck(
|
||||
id, JSON.stringify([source ?? null, background ?? false]),
|
||||
() => invoke<UpdatePreview>("check_update", { id, source: source ?? null, background: background ?? false }),
|
||||
),
|
||||
pendingUpdate: (id: string) => invoke<UpdateTxn | null>("pending_update", { id }),
|
||||
applyUpdate: (id: string, resolutions: Record<string, "keep" | "replace">, source?: string) =>
|
||||
invoke<{ txn: UpdateTxn; preexisting_character_count?: number }>("apply_update", { id, source: source ?? null, resolutions }),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function updateCheckQueue<T>() {
|
||||
const requests = new Map<string, Promise<T>>();
|
||||
const servers = new Map<string, Promise<unknown>>();
|
||||
return (server: string, request: string, run: () => Promise<T>): Promise<T> => {
|
||||
const key = JSON.stringify([server, request]);
|
||||
const existing = requests.get(key);
|
||||
if (existing) return existing;
|
||||
const previous = servers.get(server) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(run);
|
||||
requests.set(key, result);
|
||||
servers.set(server, result);
|
||||
const clear = () => {
|
||||
if (requests.get(key) === result) requests.delete(key);
|
||||
if (servers.get(server) === result) servers.delete(server);
|
||||
};
|
||||
void result.then(clear, clear);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -284,8 +284,8 @@ export function SettingsPage(props: {
|
||||
const q = query.trim().toLowerCase();
|
||||
const searching = q !== "";
|
||||
const hit = (...parts: string[]) => parts.some((x) => x.toLowerCase().includes(q));
|
||||
const curatedKeys = new Set(view.settings.map((s) => s.key));
|
||||
const collections = collectionsOf(cols);
|
||||
const curatedKeys = new Set([...view.settings.map((s) => s.key), ...collections.map((c) => c.item.key)]);
|
||||
const curatedHits = searching ? view.settings.filter((s) => hit(s.key, sx.title(s), sx.description(s))) : [];
|
||||
const collectionHits = searching ? collections.filter((c) => hit(c.item.key, t(c.label), t(c.text))) : [];
|
||||
const rawHits = (raw ?? []).filter(
|
||||
|
||||
@@ -15,6 +15,10 @@ const stubs = {
|
||||
"@/lib/clientJob": `export const dismissClientJob=()=>{}; export const startClientUpdate=()=>{}; export const stopClientJob=()=>{}; export const syncClient=()=>{}; export const useClientJob=()=>({phase:'idle'});`,
|
||||
"@/lib/clientUpdate": `export const checkClient=()=>{}; export const useClientStatus=()=>({status:null});`,
|
||||
"@/lib/autoRestartPrefs": `export const useAutoRestart=()=>[false,()=>{}];`,
|
||||
"@/screens/PublishCard": `export const ListingChip=()=>null;`,
|
||||
"@/screens/PublishDialog": `export const PublishRealmDialog=()=>null;`,
|
||||
"@/lib/registry": `export const useRegistryStatus=()=>({status:null});`,
|
||||
"@/lib/publishRealms": `export const usePublishRealms=()=>[];`,
|
||||
"@/components/ui/button": `import {createElement} from 'react'; export const Button=({children,disabled,onClick,className})=>createElement('button',{disabled,onClick,className},children);`,
|
||||
"@/components/ui/card": `import {createElement} from 'react'; export const Card=({children,...props})=>createElement('div',props,children);`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import pathlib
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
BRIDGE = pathlib.Path(__file__).resolve().parents[1] / 'crates/coa-core/src/launcher_bridge.py'
|
||||
|
||||
|
||||
class LauncherBridgeTests(unittest.TestCase):
|
||||
def launch(self, action, body):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
script = pathlib.Path(directory) / 'Scripts/manage.py'
|
||||
script.parent.mkdir()
|
||||
script.write_text(body, encoding='utf8')
|
||||
original = runpy.run_path
|
||||
def capture(*args, **kwargs):
|
||||
self.launcher = original(*args, **kwargs)
|
||||
return self.launcher
|
||||
with patch.object(sys, 'argv', ['bridge', str(script), action]), patch.object(runpy, 'run_path', side_effect=capture):
|
||||
return original(str(BRIDGE))
|
||||
|
||||
def shutdown_fixture(self, *, console_error='RuntimeError', stopped=True, foreign=False, same=True, modern=False, terminate_error=False):
|
||||
return f'''
|
||||
from pathlib import Path
|
||||
from contextlib import nullcontext
|
||||
calls = []
|
||||
world = {{'pid': 123, 'created': 456, 'exe': str(Path(__file__).resolve().parents[1] / {'"foreign.exe"' if foreign else '"Core/worldserver.exe"'})}}
|
||||
def wait_ready(*args): pass
|
||||
def start_mysql(): raise AssertionError('must not start')
|
||||
def main(): raise AssertionError('must not prepare')
|
||||
def settings(): return {{}}
|
||||
def control_lock(): return nullcontext()
|
||||
def process(name): return world if name == 'world' else None
|
||||
def same_process(record): return {same!r}
|
||||
def ra(command, config):
|
||||
calls.append('console')
|
||||
{'raise '+console_error+'("Start this repack worldserver first")' if console_error else 'return "Graceful shutdown"'}
|
||||
def terminate(record):
|
||||
assert record is world
|
||||
calls.append('terminate')
|
||||
{'raise RuntimeError("identity changed")' if terminate_error else 'pass'}
|
||||
def wait_stopped(record, seconds):
|
||||
assert record is world
|
||||
calls.append('wait')
|
||||
return {stopped!r}
|
||||
def stop_all(config):
|
||||
record = process('world')
|
||||
if record:
|
||||
if {modern!r}:
|
||||
try: ra('server shutdown 1s 0', config)
|
||||
except (OSError, RuntimeError): terminate(record)
|
||||
else:
|
||||
ra('server shutdown 1s 0', config)
|
||||
if not wait_stopped(record, 60): raise RuntimeError('world still alive')
|
||||
calls.append('mysql-shutdown')
|
||||
'''
|
||||
|
||||
def test_unavailable_console_in_restored_legacy_launcher_stops_owned_world_before_mysql(self):
|
||||
for error in ['RuntimeError', 'OSError']:
|
||||
with self.subTest(error=error):
|
||||
result = self.launch('stop-all', self.shutdown_fixture(console_error=error))
|
||||
self.assertEqual(result['launcher']['calls'], ['console', 'terminate', 'wait', 'wait', 'mysql-shutdown'])
|
||||
|
||||
def test_healthy_console_keeps_graceful_shutdown(self):
|
||||
result = self.launch('stop-all', self.shutdown_fixture(console_error=None))
|
||||
self.assertEqual(result['launcher']['calls'], ['console', 'wait', 'mysql-shutdown'])
|
||||
|
||||
def test_foreign_or_reused_process_is_never_terminated_even_by_a_modern_launcher_catch(self):
|
||||
for modern in [False, True]:
|
||||
for identity in [{'foreign': True}, {'same': False}]:
|
||||
with self.subTest(modern=modern, **identity):
|
||||
with self.assertRaisesRegex(Exception, 'ownership could not be verified'):
|
||||
self.launch('stop-all', self.shutdown_fixture(modern=modern, **identity))
|
||||
self.assertEqual(self.launcher['calls'], ['console'])
|
||||
|
||||
def test_world_that_stays_alive_blocks_database_shutdown(self):
|
||||
for modern in [False, True]:
|
||||
with self.subTest(modern=modern):
|
||||
with self.assertRaisesRegex(Exception, 'database was left running'):
|
||||
self.launch('stop-all', self.shutdown_fixture(stopped=False, modern=modern))
|
||||
self.assertEqual(self.launcher['calls'], ['console', 'terminate', 'wait'])
|
||||
|
||||
def test_termination_identity_failure_leaves_mysql_untouched(self):
|
||||
for modern in [False, True]:
|
||||
with self.subTest(modern=modern):
|
||||
with self.assertRaisesRegex(Exception, 'could not be stopped safely'):
|
||||
self.launch('stop-all', self.shutdown_fixture(terminate_error=True, modern=modern))
|
||||
self.assertEqual(self.launcher['calls'], ['console', 'terminate'])
|
||||
|
||||
def test_slow_mysql_gets_time_to_finish_but_other_waits_are_unchanged(self):
|
||||
result = self.launch('start-mysql', '''
|
||||
calls = []
|
||||
def wait_ready(name, port, seconds=180):
|
||||
calls.append((name, seconds))
|
||||
if name == 'mysql' and seconds < 162:
|
||||
raise RuntimeError('mysql is still starting')
|
||||
def start_mysql():
|
||||
wait_ready('mysql', 12345, 60)
|
||||
def main():
|
||||
start_mysql()
|
||||
wait_ready('world', 23456, 180)
|
||||
wait_ready('mysql', 12345, 900)
|
||||
''')
|
||||
self.assertEqual(result['launcher']['calls'], [('mysql', 300), ('world', 180), ('mysql', 900)])
|
||||
|
||||
def test_stop_dispatch_does_not_prepare_or_start_anything(self):
|
||||
result = self.launch('stop-all', '''
|
||||
from contextlib import nullcontext
|
||||
calls = []
|
||||
def wait_ready(*args): raise AssertionError('must not wait')
|
||||
def start_mysql(): raise AssertionError('must not start')
|
||||
def main(): raise AssertionError('must not prepare')
|
||||
def settings(): return {'mysqlPort': 12345}
|
||||
def control_lock(): return nullcontext()
|
||||
def stop_all(config): calls.append(config['mysqlPort'])
|
||||
''')
|
||||
self.assertEqual(result['launcher']['calls'], [12345])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { updateCheckQueue } from "../src/lib/updateCheckQueue.ts";
|
||||
|
||||
test("duplicate checks share one result and foreground waits for background on the same server", async () => {
|
||||
const check = updateCheckQueue();
|
||||
let release;
|
||||
let calls = 0;
|
||||
const background = check("server", "background", () => { calls++; return new Promise(resolve => { release = resolve; }); });
|
||||
assert.equal(check("server", "background", () => { throw Error("duplicate"); }), background);
|
||||
const foreground = check("server", "foreground", async () => { calls++; return "full preview"; });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(calls, 1);
|
||||
release("background preview");
|
||||
assert.equal(await background, "background preview");
|
||||
assert.equal(await foreground, "full preview");
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("one server's failure releases its queue and does not block other servers", async () => {
|
||||
const check = updateCheckQueue();
|
||||
let fail;
|
||||
const first = check("one", "a", () => new Promise((_, reject) => { fail = reject; }));
|
||||
const failed = assert.rejects(first, /offline/);
|
||||
const next = check("one", "b", async () => "retry");
|
||||
assert.equal(await check("two", "a", async () => "independent"), "independent");
|
||||
fail(Error("offline"));
|
||||
await failed;
|
||||
assert.equal(await next, "retry");
|
||||
assert.equal(await check("one", "a", async () => "recovered"), "recovered");
|
||||
});
|
||||
Reference in new issue
Block a user