mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Update older versions directly using database migration history (#112)
This commit is contained in:
1 parent
a7b56d6aed
commit
e3fbb2e4b6
10 files changed
+79
-151
No files matched your search
Generated
+3
-3
@@ -491,7 +491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-core"
|
||||
version = "0.6.20"
|
||||
version = "0.6.21"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -520,7 +520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-release"
|
||||
version = "0.6.20"
|
||||
version = "0.6.21"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"coa-core",
|
||||
@@ -529,7 +529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-server-manager"
|
||||
version = "0.6.20"
|
||||
version = "0.6.21"
|
||||
dependencies = [
|
||||
"coa-core",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.20"
|
||||
version = "0.6.21"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -93,6 +93,7 @@ pub struct Compatibility {
|
||||
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>,
|
||||
|
||||
@@ -387,8 +387,10 @@ fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> {
|
||||
if c.platform != platform {
|
||||
return Err(Error::Invalid(format!("This package targets {} rather than {platform}.", c.platform)));
|
||||
}
|
||||
if meta.core.version.as_ref().is_none_or(|version| version != &manifest.version && !c.source_versions.contains(version)) {
|
||||
return Err(Error::Invalid(format!("Installed server {} is outside the compatibility matrix for {}. Tested source versions: {}.", meta.core.version.as_deref().unwrap_or("unknown"), manifest.version, c.source_versions.join(", "))));
|
||||
// The source list records release qualification, not an exclusive upgrade route.
|
||||
// Database history and target validation decide whether the update can complete.
|
||||
if meta.core.version.as_deref().and_then(crate::manifest::parse_version).is_none() {
|
||||
return Err(Error::Invalid("The installed server version is unknown. An imported repack needs a verified starting state before updating.".into()));
|
||||
}
|
||||
}
|
||||
if let Some(installed) = meta.core.version.as_deref() {
|
||||
@@ -1630,7 +1632,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_source_or_platform_is_rejected_without_touching_installation() {
|
||||
fn unlisted_source_is_allowed_but_unknown_versions_and_other_platforms_are_rejected() {
|
||||
let w = world(&[], false);
|
||||
let (_, meta) = MetaDir::open(&w.meta).unwrap();
|
||||
let mut manifest = Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap();
|
||||
@@ -1643,7 +1645,12 @@ mod tests {
|
||||
manifest.validate().unwrap();
|
||||
validate_candidate(&meta, &manifest).unwrap();
|
||||
manifest.compatibility.as_mut().unwrap().source_versions = vec!["0.9.0".into()];
|
||||
assert!(validate_candidate(&meta, &manifest).unwrap_err().to_string().contains("compatibility matrix"));
|
||||
validate_candidate(&meta, &manifest).unwrap();
|
||||
let mut unknown = meta.clone();
|
||||
unknown.core.version = None;
|
||||
assert!(validate_candidate(&unknown, &manifest).unwrap_err().to_string().contains("unknown"));
|
||||
unknown.core.version = Some("unverified-repack".into());
|
||||
assert!(validate_candidate(&unknown, &manifest).is_err());
|
||||
let mut already_updated = meta.clone();
|
||||
already_updated.core.version = Some(manifest.version.clone());
|
||||
validate_candidate(&already_updated, &manifest).unwrap();
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{fsx, manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Error, Result};
|
||||
|
||||
const RELEASES: &str = "https://github.com/Corfirean/coa-server-build/releases/download/server-";
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -16,48 +13,8 @@ pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result<Selection
|
||||
|
||||
fn select_with(meta: &InstallMeta, source: Source, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec<u8>)>) -> Result<Selection> {
|
||||
let (manifest, _) = fetch(&source)?;
|
||||
if update::validate_candidate(meta, &manifest).is_ok() {
|
||||
return Ok(Selection { source, latest_version: None });
|
||||
}
|
||||
let original = update::validate_candidate(meta, &manifest).unwrap_err();
|
||||
if !matches!(&source, Source::Url(url) if url.trim_end_matches('/') == format!("{RELEASES}{}", manifest.version)) {
|
||||
return Err(original);
|
||||
}
|
||||
let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Err(original); };
|
||||
if manifest.kind != crate::manifest::Kind::Update
|
||||
|| manifest.compatibility.as_ref().is_none_or(|c| c.platform != if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" })
|
||||
|| !manifest.compatible_with_manager(crate::MANAGER_VERSION)
|
||||
|| crate::manifest::parse_version(&manifest.version).is_none_or(|version| version <= installed) {
|
||||
return Err(original);
|
||||
}
|
||||
let mut visited = BTreeSet::new();
|
||||
let Some(source) = find(meta, &manifest, fetch, &mut visited, 0)? else { return Err(original); };
|
||||
Ok(Selection { source, latest_version: Some(manifest.version) })
|
||||
}
|
||||
|
||||
fn find(meta: &InstallMeta, manifest: &Manifest, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec<u8>)>, visited: &mut BTreeSet<String>, depth: usize) -> Result<Option<Source>> {
|
||||
if depth >= 8 || visited.len() >= 32 { return Ok(None); }
|
||||
let Some(compatibility) = &manifest.compatibility else { return Ok(None); };
|
||||
let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Ok(None); };
|
||||
let target = crate::manifest::parse_version(&manifest.version).unwrap();
|
||||
let mut versions: Vec<_> = compatibility.source_versions.iter().filter_map(|version| {
|
||||
crate::manifest::parse_version(version).filter(|number| *number > installed && *number < target).map(|number| (number, version))
|
||||
}).collect();
|
||||
versions.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
for (_, version) in versions {
|
||||
let Some(database) = compatibility.source_databases.get(version) else { continue; };
|
||||
if !visited.insert(version.clone()) || visited.len() > 32 { continue; }
|
||||
let source = Source::Url(format!("{RELEASES}{version}"));
|
||||
let (bridge, bytes) = fetch(&source)?;
|
||||
if bridge.version != *version || fsx::sha256_bytes(&bytes) != database.manifest_sha256 {
|
||||
return Err(Error::InvalidManifest("The intermediate update differs from the signed compatibility matrix.".into()));
|
||||
}
|
||||
if update::validate_candidate(meta, &bridge).is_ok() { return Ok(Some(source)); }
|
||||
if bridge.kind != crate::manifest::Kind::Update || !bridge.compatible_with_manager(crate::MANAGER_VERSION)
|
||||
|| bridge.compatibility.as_ref().is_none_or(|c| c.platform != compatibility.platform) { continue; }
|
||||
if let Some(source) = find(meta, &bridge, fetch, visited, depth + 1)? { return Ok(Some(source)); }
|
||||
}
|
||||
Ok(None)
|
||||
update::validate_candidate(meta, &manifest)?;
|
||||
Ok(Selection { source, latest_version: None })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -67,12 +24,12 @@ mod tests {
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn package(version: &str, from: &str, digest: &str) -> serde_json::Value {
|
||||
serde_json::json!({"schema":1,"kind":"update","version":version,"core":{"commit":"a".repeat(40)},
|
||||
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":[from],"sourceDatabases":{from:{"manifestSha256":digest,"schemaSha256":null}}}})
|
||||
"sourceVersions":["0.261013.54"],"sourceDatabases":{"0.261013.54":{"manifestSha256":"c".repeat(64),"schemaSha256":null}}}})
|
||||
}
|
||||
|
||||
fn meta(version: &str) -> InstallMeta {
|
||||
@@ -81,100 +38,56 @@ mod tests {
|
||||
meta
|
||||
}
|
||||
|
||||
fn signed_fixture(values: &[serde_json::Value], tamper: Option<&str>) -> (tempfile::TempDir, String) {
|
||||
fn fixture(value: &serde_json::Value, tamper: bool) -> (tempfile::TempDir, String) {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let signing = SigningKey::from_bytes(&[37; 32]);
|
||||
for value in values {
|
||||
let version = value["version"].as_str().unwrap();
|
||||
let dir = folder.path().join(version);
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let bytes = serde_json::to_vec(value).unwrap();
|
||||
fs::write(dir.join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap();
|
||||
fs::write(dir.join("manifest.json"), &bytes).unwrap();
|
||||
if tamper == Some(version) { fs::write(dir.join("manifest.json"), b"tampered").unwrap(); }
|
||||
}
|
||||
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()))
|
||||
}
|
||||
|
||||
fn select_fixture(values: &[serde_json::Value], installed: &str, tamper: Option<&str>) -> Result<Selection> {
|
||||
let (folder, key) = signed_fixture(values, tamper);
|
||||
let latest = values.last().unwrap()["version"].as_str().unwrap();
|
||||
select_with(&meta(installed), Source::Url(format!("{RELEASES}{latest}")), &mut |source| {
|
||||
let Source::Url(url) = source else { unreachable!() };
|
||||
let version = url.strip_prefix(RELEASES).unwrap();
|
||||
pkgsource::fetch_manifest(&Source::Dir(folder.path().join(version)), &key)
|
||||
})
|
||||
}
|
||||
|
||||
fn pair() -> Vec<serde_json::Value> {
|
||||
let bridge = package("1.1.0", "1.0.0", &"c".repeat(64));
|
||||
let digest = fsx::sha256_bytes(&serde_json::to_vec(&bridge).unwrap());
|
||||
vec![bridge, package("1.2.0", "1.1.0", &digest)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_signed_intermediate_without_relaxing_candidate_validation() {
|
||||
let selection = select_fixture(&pair(), "1.0.0", None).unwrap();
|
||||
assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0")));
|
||||
assert_eq!(selection.latest_version.as_deref(), Some("1.2.0"));
|
||||
let latest = Manifest::parse(&serde_json::to_vec(pair().last().unwrap()).unwrap()).unwrap();
|
||||
assert!(update::validate_candidate(&meta("1.0.0"), &latest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_updates_and_current_versions_do_not_request_an_intermediate() {
|
||||
for installed in ["1.1.0", "1.2.0"] {
|
||||
let selection = select_fixture(&pair(), installed, None).unwrap();
|
||||
assert!(selection.latest_version.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_or_replaced_intermediate_manifests() {
|
||||
assert!(select_fixture(&pair(), "1.0.0", Some("1.1.0")).is_err());
|
||||
let mut values = pair();
|
||||
values[0]["builtAt"] = "replaced".into();
|
||||
assert!(select_fixture(&values, "1.0.0", None).unwrap_err().to_string().contains("differs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_multiple_tested_edges_and_rejects_missing_paths() {
|
||||
let mut values = pair();
|
||||
let digest = fsx::sha256_bytes(&serde_json::to_vec(values.last().unwrap()).unwrap());
|
||||
values.push(package("1.3.0", "1.2.0", &digest));
|
||||
let selection = select_fixture(&values, "1.0.0", None).unwrap();
|
||||
assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0")));
|
||||
assert!(select_fixture(&values, "0.9.0", None).is_err());
|
||||
assert!(select_fixture(&values, "2.0.0", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_routes_custom_sources_or_unknown_installations() {
|
||||
let values = pair();
|
||||
let (folder, key) = signed_fixture(&values, None);
|
||||
let mut calls = 0;
|
||||
let result = select_with(&meta("1.0.0"), Source::Dir(folder.path().join("1.2.0")), &mut |source| {
|
||||
calls += 1;
|
||||
pkgsource::fetch_manifest(source, &key)
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(calls, 1);
|
||||
assert!(select_fixture(&values, "unknown", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incompatible_platforms_and_manager_versions() {
|
||||
for index in [0, 1] {
|
||||
for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] {
|
||||
let mut values = pair();
|
||||
if field == "platform" { values[index]["compatibility"][field] = value.into(); }
|
||||
else { values[index][field] = value.into(); }
|
||||
if index == 0 {
|
||||
let digest = fsx::sha256_bytes(&serde_json::to_vec(&values[0]).unwrap());
|
||||
values[1]["compatibility"]["sourceDatabases"]["1.1.0"]["manifestSha256"] = digest.into();
|
||||
}
|
||||
assert!(select_fixture(&values, "1.0.0", None).is_err());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Update workflow
|
||||
|
||||
Version compatibility selects a package; it does not prove that a particular installed database can apply it.
|
||||
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.
|
||||
|
||||
@@ -8,7 +8,7 @@ Account migration history is stored in the shared account database. Read-only ch
|
||||
|
||||
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 has 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, no unexecuted SQL is marked applied, and the package compatibility matrix is unchanged.
|
||||
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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "coa-server-manager",
|
||||
"version": "0.6.20",
|
||||
"version": "0.6.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "coa-server-manager",
|
||||
"version": "0.6.20",
|
||||
"version": "0.6.21",
|
||||
"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.6.20",
|
||||
"version": "0.6.21",
|
||||
"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.6.20",
|
||||
"version": "0.6.21",
|
||||
"identifier": "com.coa.servermanager",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user