From d6a6384f9c6c793081131d1beeebe24db3e09f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 16 Oct 2026 05:54:25 +0500 Subject: [PATCH] Keep update previews local and preflight the complete rehearsal footprint --- .../20261016-lightweight-update-checks.md | 7 ++++ crates/coa-core/src/mysql_snapshot.rs | 16 ++++++++++ crates/coa-core/src/update.rs | 32 ++++++------------- 3 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 changelog.d/20261016-lightweight-update-checks.md diff --git a/changelog.d/20261016-lightweight-update-checks.md b/changelog.d/20261016-lightweight-update-checks.md new file mode 100644 index 0000000..03fa93a --- /dev/null +++ b/changelog.d/20261016-lightweight-update-checks.md @@ -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. diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 0e078a1..9a91cf8 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -257,6 +257,7 @@ pub(crate) fn rehearsal_copy_with_progress(root: &Path, meta: &Path, point: &Pat let root = canonical.as_path(); let destination = meta.join("rehearsals").join(uuid::Uuid::new_v4().simple().to_string()).join("server"); let mut files = BTreeMap::new(); + let mut file_bytes = 0u64; let mut directories = vec![root.to_path_buf()]; while let Some(directory) = directories.pop() { for entry in fs::read_dir(directory)? { @@ -275,12 +276,15 @@ pub(crate) fn rehearsal_copy_with_progress(root: &Path, meta: &Path, point: &Pat let path = fsx::ensure_within(root, &raw)?; if metadata.is_dir() { directories.push(path); } else if metadata.is_file() { + file_bytes = file_bytes.checked_add(metadata.len()).ok_or_else(|| Error::Invalid("Private copy size overflow.".into()))?; files.insert(relative, fsx::sha256_file(&path)?); if files.len() % 250 == 0 { report(&format!("Preparing the private copy: {} files checked", files.len())); } } else { return Err(Error::Invalid("Unsupported rehearsal file.".into())); } } } + let needed = file_bytes.checked_add(snapshot.expanded_bytes.max(snapshot.bytes)).ok_or_else(|| Error::Invalid("Private copy size overflow.".into()))?; + fsx::require_space(meta, needed)?; copy_verified_with_progress(root, &destination, &files, report)?; report("Restoring MySQL into the private server copy"); copy_snapshot(point, snapshot, &destination.join("mysql/data"))?; @@ -701,4 +705,16 @@ mod tests { assert!(phases.iter().any(|phase| phase.contains("Copying private server files"))); assert!(phases.last().unwrap().contains("Restoring MySQL")); } + + #[test] + fn rehearsal_checks_combined_program_and_database_space_before_copying() { + let (_temp, root, meta, point, mut snapshot) = fixture(); + fs::create_dir_all(&meta).unwrap(); + fsx::atomic_write(&root.join("Core/worldserver.exe"), b"exe").unwrap(); + snapshot.expanded_bytes = fsx::free_space(&meta).unwrap().saturating_add(1024 * 1024 * 1024); + let error = rehearsal_copy_with_progress(&root, &meta, &point, &snapshot, &|_| {}).unwrap_err(); + assert!(matches!(error, Error::InsufficientSpace { .. })); + assert!(!meta.join("rehearsals").exists()); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live"); + } } diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 1ececb9..c596428 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -339,7 +339,7 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke let meta_dir = crate::registry::metadata_dir_for(root)?; let observed_version = MetaDir::open(&meta_dir)?.1.core.version; let (m, _) = fetch_manifest(source, trusted_key)?; - let _lock = operation_lock_for(&meta_dir, "checking updates", false)?; + let _lock = operation_lock_for(&meta_dir, "checking updates", true)?; ensure_recovered(&meta_dir)?; let (_, current_meta) = MetaDir::open(&meta_dir)?; let meta = if current_meta.core.version != observed_version { ¤t_meta } else { meta }; @@ -347,7 +347,7 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke reject_downgrade(meta, &m)?; let mut items = plan(root, meta, &m, resolutions, None)?; if items.iter().any(|item| item.path == "Scripts/manage.py" && item.action != Action::Skip) - && launcher_already_integrated(root, &meta_dir, meta, &m, source)? { + && launcher_already_integrated(root, meta, &m)? { if let Some(item) = items.iter_mut().find(|item| item.path == "Scripts/manage.py") { item.action = Action::Skip; item.reason = Some("already up to date with Manager integration".into()); @@ -377,27 +377,11 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke }) } -fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, manifest: &Manifest, source: &Source) -> Result { +fn launcher_already_integrated(root: &Path, meta: &InstallMeta, manifest: &Manifest) -> Result { let Some(file) = manifest.files.iter().find(|file| file.path == "Scripts/manage.py") else { return Ok(false); }; - let actual = sha_if_exists(&root.join(&file.path)); - if actual.as_ref() != meta.original_hashes.get(&file.path) || actual.is_none() { return Ok(false); } - let cache = meta_dir.join("manifests").join(format!("launcher-{}.py", file.sha256)); - let signed = match fs::read(&cache) { - Ok(bytes) if fsx::sha256_bytes(&bytes) == file.sha256 => bytes, - _ => { - let temp = tempfile::tempdir()?; - let parts = fetch_parts(source, manifest, &temp.path().join("download"), &Cancel::default(), &|_, _| {})?; - package::extract(&parts, manifest, &temp.path().join("tree"), &|_, _| {})?; - let bytes = fs::read(temp.path().join("tree").join(&file.path))?; - if fsx::sha256_bytes(&bytes) != file.sha256 { return Err(Error::Invalid("The signed launcher failed verification.".into())); } - // This derived cache is optional; failure to save it does not change the server. - let _ = fsx::atomic_write(&cache, &bytes); - bytes - } - }; - Ok(std::str::from_utf8(&signed).ok().is_some_and(|signed| { - fs::read(root.join(&file.path)).ok().is_some_and(|actual| crate::driver::launcher_matches(signed, &actual)) - })) + let Ok(actual) = fs::read(root.join(&file.path)) else { return Ok(false); }; + if meta.original_hashes.get(&file.path) != Some(&fsx::sha256_bytes(&actual)) { return Ok(false); } + Ok(crate::driver::launcher_matches_recorded(&file.sha256, &actual)) } fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { @@ -1584,6 +1568,8 @@ mod tests { let (_, mut meta) = MetaDir::open(&w.meta).unwrap(); meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(integrated.as_bytes())); fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap(); + let original = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; + for part in &original.archive.as_ref().unwrap().parts { fs::remove_file(w.pkg.join(&part.name)).unwrap(); } let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip); let manifest = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; @@ -1591,7 +1577,7 @@ mod tests { assert_eq!(applied_plan.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip); let mut changed = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; changed.files.iter_mut().find(|file| file.path == "Scripts/manage.py").unwrap().sha256 = fsx::sha256_bytes(b"new launcher"); - assert!(launcher_already_integrated(&w.root, &w.meta, &meta, &changed, &Source::Dir(w.pkg.clone())).is_err()); + assert!(!launcher_already_integrated(&w.root, &meta, &changed).unwrap()); write(&w.root, "Scripts/manage.py", format!("{integrated}\n# user edit\n").as_bytes()); // Same version: the owner's edit is not an update (Repair lists and restores it). let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();