From cd3ada8f5b770df47eba5628b24516ffdaaed537 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 11:17:44 +0500 Subject: [PATCH] Share migration planning and execution across update phases --- .../20261016-consistent-update-migrations.md | 7 ++ .../20261016-idempotent-validation-stop.md | 7 ++ .../20261016-update-compatibility-wording.md | 7 ++ crates/coa-core/examples/migration_plan.rs | 57 +++++++++++ crates/coa-core/examples/upgrade_fixture.rs | 19 ++++ crates/coa-core/src/migrations.rs | 42 ++++++-- crates/coa-core/src/update.rs | 95 ++++++++----------- docs/UPDATE-WORKFLOW.md | 15 +++ src/i18n/locales/en.ts | 2 +- src/i18n/locales/ru.ts | 2 +- 10 files changed, 191 insertions(+), 62 deletions(-) create mode 100644 changelog.d/20261016-consistent-update-migrations.md create mode 100644 changelog.d/20261016-idempotent-validation-stop.md create mode 100644 changelog.d/20261016-update-compatibility-wording.md create mode 100644 crates/coa-core/examples/migration_plan.rs create mode 100644 crates/coa-core/examples/upgrade_fixture.rs create mode 100644 docs/UPDATE-WORKFLOW.md diff --git a/changelog.d/20261016-consistent-update-migrations.md b/changelog.d/20261016-consistent-update-migrations.md new file mode 100644 index 0000000..bddc9da --- /dev/null +++ b/changelog.d/20261016-consistent-update-migrations.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Rehearsal and installation use the same database migration checks +--- +Both phases use one migration executor and check both realms before changing either. Duplicate migration entries are rejected before SQL runs. diff --git a/changelog.d/20261016-idempotent-validation-stop.md b/changelog.d/20261016-idempotent-validation-stop.md new file mode 100644 index 0000000..73b9541 --- /dev/null +++ b/changelog.d/20261016-idempotent-validation-stop.md @@ -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. diff --git a/changelog.d/20261016-update-compatibility-wording.md b/changelog.d/20261016-update-compatibility-wording.md new file mode 100644 index 0000000..0ccd451 --- /dev/null +++ b/changelog.d/20261016-update-compatibility-wording.md @@ -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. diff --git a/crates/coa-core/examples/migration_plan.rs b/crates/coa-core/examples/migration_plan.rs new file mode 100644 index 0000000..db5fc57 --- /dev/null +++ b/crates/coa-core/examples/migration_plan.rs @@ -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> { + let args: Vec<_> = std::env::args().collect(); + if args.len() < 3 { return Err("usage: migration_plan [--apply|--sql-only ]".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::>()); + } + 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 { + 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(()) +} diff --git a/crates/coa-core/examples/upgrade_fixture.rs b/crates/coa-core/examples/upgrade_fixture.rs new file mode 100644 index 0000000..87f0a20 --- /dev/null +++ b/crates/coa-core/examples/upgrade_fixture.rs @@ -0,0 +1,19 @@ +//! 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> { + let args:Vec<_>=std::env::args().collect(); + if args.len()!=3 {return Err("usage: upgrade_fixture ".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 _=MetaDir::open(&meta)?; + let env=RepackEnv{root,meta_dir:&meta}; + 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(()) +} diff --git a/crates/coa-core/src/migrations.rs b/crates/coa-core/src/migrations.rs index 7d95645..0b88899 100644 --- a/crates/coa-core/src/migrations.rs +++ b/crates/coa-core/src/migrations.rs @@ -183,8 +183,7 @@ pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> { pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result { let rows = store.load_readonly()?; - check_history(&rows, list)?; - Ok(list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).count()) + Ok(pending_migrations(&rows, list)?.len()) } fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> { @@ -199,15 +198,30 @@ fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path:: /// Check every migration artifact, including applied history, before replacing server files. pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> { + validate_list(list)?; + for m in list { verified_file(m, dir)?; } + Ok(()) +} + +fn validate_list(list: &[Migration]) -> Result<()> { let mut seen = std::collections::BTreeSet::new(); for m in list { - if !seen.insert((&m.db, &m.id)) { return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); } - verified_file(m, dir)?; + if !ident_ok(&m.id) { return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id))); } + let schema = db::schema_of(&m.db)?; + if !seen.insert((schema, &m.id)) { + return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); + } } Ok(()) } +fn pending_migrations<'a>(rows: &[LedgerRow], list: &'a [Migration]) -> Result> { + 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 { if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) { return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db))); @@ -246,8 +260,7 @@ pub struct ApplyReport { pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result) -> Result { let rows = store.load()?; // The list order is the release's order of application (released updates, then pending, then modules). - check_history(&rows, list)?; - let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect(); + let todo = pending_migrations(&rows, list)?; tracing::info!(total = list.len(), pending = todo.len(), skipped = list.len() - todo.len(), "database migration plan"); // Resolve and verify every file before running anything. @@ -368,6 +381,23 @@ mod tests { 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] fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() { let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index c596428..8bd041e 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -1010,16 +1010,9 @@ impl Env for RepackEnv<'_> { step(report, "Testing database updates on the private server copy", 56); let identities = crate::backup::with_database(&fixture, |db| { let identities = database_identities(db)?; - let realms = crate::realms::state(&fixture)?; - let mut modes = vec![realms.active]; - if realms.wildcard_created { modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); } - for (index, mode) in modes.into_iter().enumerate() { - let db = db.clone().for_realm(mode); - crate::schema_check::repair_missing_defaults(&db, &fixture, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; - let list: Vec<_> = manifest.migrations.iter().filter(|migration| index == 0 || migration.db != "auth").cloned().collect(); - let report = crate::migrations::apply_pending(&db, &list, &tree.join("_migrations"), &|| Ok("private rehearsal".into()))?; - if let Some((migration, error)) = report.failed { return Err(Error::Invalid(format!("Rehearsal migration {migration} failed: {error}"))); } - crate::schema_check::repair_missing_defaults(&db, &fixture, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; + let result = migrate_databases(db, &fixture, manifest, &tree.join("_migrations"), &|| Ok("private rehearsal".into()))?; + if let Some((migration, error)) = result.failed { + return Err(Error::Invalid(format!("Rehearsal migration {migration} failed: {error}"))); } verify_database_identities(db, &identities)?; Ok(identities) @@ -1126,44 +1119,8 @@ impl Env for RepackEnv<'_> { } fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result { - let root = self.root; - crate::backup::with_database(root, |db| { - let realms = crate::realms::state(root)?; - if let Some(contract) = manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT) { - self.migration_snapshot()?; - crate::schema_check::repair_missing_defaults(db, root, Some(&contract.sha256))?; - } - let mut result = crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| { - self.migration_snapshot() - })?; - if realms.wildcard_created && result.failed.is_none() { - let other = if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }; - let other_db = db.clone().for_realm(other); - crate::schema_check::repair_missing_defaults(&other_db, root, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; - let shared_snapshot = result.snapshot.clone(); - let migrations: Vec<_> = manifest.migrations.iter().filter(|m| m.db != "auth").cloned().collect(); - let extra = crate::migrations::apply_pending(&other_db, &migrations, staged, &|| { - if let Some(id) = &shared_snapshot { return Ok(id.clone()); } - self.migration_snapshot() - })?; - result.applied.extend(extra.applied); - result.failed = extra.failed; - if result.snapshot.is_none() { result.snapshot = extra.snapshot; } - if result.failed.is_none() { - crate::schema_check::repair_missing_defaults(&other_db, root, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; - if let Some(p) = crate::schema_check::check_with_report(&other_db, root)?.first() { - return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", other.name(), p.table, p.column, p.detail))); - } - } - } - if result.failed.is_none() { - crate::schema_check::repair_missing_defaults(db, root, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; - let problems = crate::schema_check::check_with_report(db, root)?; - if let Some(p) = problems.first() { - return Err(Error::Invalid(format!("Database validation failed: {}.{}.{}: {} ({} problems).", p.database, p.table, p.column, p.detail, problems.len()))); - } - } - Ok(result) + crate::backup::with_database(self.root, |db| { + migrate_databases(db, self.root, manifest, staged, &|| self.migration_snapshot()) }) } @@ -1178,8 +1135,7 @@ impl Env for RepackEnv<'_> { impl RepackEnv<'_> { fn validate_server(&self, check_schema: bool) -> Result<()> { - let stopped = crate::driver::run(self.root, crate::driver::Verb::StopAll)?; - if !stopped.ok { return Err(Error::Invalid("Could not stop the server before isolated validation.".into())); } + crate::mysql_snapshot::stop(self.root)?; self.ensure_stopped()?; self.recover_validation()?; let result = (|| -> Result<()> { @@ -1239,10 +1195,7 @@ impl RepackEnv<'_> { let _ = crate::driver::run(self.root, crate::driver::Verb::StopAll); Err(Error::Invalid(cause)) })(); - let stopped = crate::driver::run(self.root, crate::driver::Verb::StopAll)?; - if !stopped.ok { - return Err(Error::Invalid("Isolated validation could not stop the server; recovery configurations were preserved.".into())); - } + crate::mysql_snapshot::stop(self.root)?; self.ensure_stopped()?; self.recover_validation()?; result @@ -1250,6 +1203,40 @@ impl RepackEnv<'_> { } +/// Rehearsal and installation use the same database order, history checks and SQL executor. +fn migrate_databases(db: &crate::db::Db, root: &Path, manifest: &Manifest, staged: &Path, snapshot: &dyn Fn() -> Result) -> Result { + let realms = crate::realms::state(root)?; + let mut modes = vec![realms.active]; + if realms.wildcard_created { + modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); + } + // Reject a broken history in either realm before changing the first one. + for (index, mode) in modes.iter().enumerate() { + let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || m.db != "auth").cloned().collect(); + crate::migrations::preflight(&db.clone().for_realm(*mode), &list)?; + } + let contract = manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()); + let mut result = ApplyReport { applied: vec![], failed: None, snapshot: None }; + if contract.is_some() { result.snapshot = Some(snapshot()?); } + for (index, mode) in modes.into_iter().enumerate() { + let db = db.clone().for_realm(mode); + let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || m.db != "auth").cloned().collect(); + crate::schema_check::repair_missing_defaults(&db, root, contract)?; + let extra = crate::migrations::apply_pending(&db, &list, staged, &|| { + if let Some(id) = &result.snapshot { Ok(id.clone()) } else { snapshot() } + })?; + result.applied.extend(extra.applied); + result.failed = extra.failed; + if result.snapshot.is_none() { result.snapshot = extra.snapshot; } + if result.failed.is_some() { return Ok(result); } + crate::schema_check::repair_missing_defaults(&db, root, contract)?; + if let Some(p) = crate::schema_check::check_with_report(&db, root)?.first() { + return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.table, p.column, p.detail))); + } + } + Ok(result) +} + fn database_identities(db: &crate::db::Db) -> Result>> { let db = db.clone().for_realm(crate::realms::Mode::Coa); let mut identities = BTreeMap::new(); diff --git a/docs/UPDATE-WORKFLOW.md b/docs/UPDATE-WORKFLOW.md new file mode 100644 index 0000000..9a57352 --- /dev/null +++ b/docs/UPDATE-WORKFLOW.md @@ -0,0 +1,15 @@ +# Update workflow + +Version compatibility selects a package; it does not prove that a particular installed database can apply it. + +The updater verifies the signed manifest and archive, checks file changes, stops the server, and saves one complete recovery point. Rehearsal and installation call the same database migration executor. That executor checks both realms before changing either, applies shared authentication migrations once, and uses the same pending-SQL selection as the preview count. Applied migrations are skipped; changed checksums and interrupted/failed records stop the update. Duplicate SQL identifiers for the same schema are rejected before execution. + +The existing recovery journal, configuration merge, private-copy checks and startup verification remain enabled. This change does not relax the package compatibility matrix, force a migration through its SQL guards, or mark unexecuted SQL as applied. + +## Verify with an actual installation copy + +`cargo run -p coa-core --example upgrade_fixture -- ` 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 ` reads database history without creating a migration ledger. On a separate marked `update-database-diagnosis-*` copy, `--apply ` exercises the production database phase. `--sql-only ` 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. diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index bc0eb4d..cd158eb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -430,7 +430,7 @@ const en = { "upd.folderHint": "Update package folder (leave empty to use the official source)", "upd.available": "Version {to} is available", "upd.availableFrom": "Version {to} is available (you have {from})", - "upd.intermediate": "Update to {to} first, then check again to continue to {latest}. Each step uses a tested upgrade path.", + "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.summaryDb": "Download {size} · {files} file(s) change · {db} database update(s)", "upd.conflict": "This file was modified outside CoA Server Manager.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index ab433fd..3b8d2b4 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -412,7 +412,7 @@ const ru: Partial> = { "upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)", "upd.available": "Доступна версия {to}", "upd.availableFrom": "Доступна версия {to} (у вас {from})", - "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Каждый этап использует проверенный путь обновления.", + "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Пакет поддерживает эту последовательность версий; состояние вашей базы проверяется при обновлении.", "upd.summary": "Загрузка {size} · изменится файлов: {files}", "upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}", "upd.conflict": "Этот файл был изменён вне CoA Server Manager.",