mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Keep complete schema diagnostics across update rollback
This commit is contained in:
1 parent
e34ab45443
commit
dc2bffc57e
4 files changed
+64
-5
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Failed database checks keep a complete report after rollback
|
||||
---
|
||||
Schema errors show readable default values and save every detected difference for troubleshooting, including after the server is restored.
|
||||
@@ -39,6 +39,34 @@ pub fn require_release_contract(root: &Path) -> Result<Contract> {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Problem { pub database: String, pub table: String, pub column: String, pub detail: String }
|
||||
|
||||
/// Keep all differences for support; never change a database to match a contract automatically.
|
||||
pub fn check_with_report(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
||||
let problems = check(db, root)?;
|
||||
let report = serde_json::json!({
|
||||
"schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"realm": db.realm().name(), "problems": problems,
|
||||
});
|
||||
let path = crate::registry::metadata_dir_for(root)?.join("diagnostics")
|
||||
.join(format!("schema-validation-{}.json", db.realm().name()));
|
||||
// A log write failure must neither hide the original mismatch nor accept an invalid schema.
|
||||
if let Err(error) = fsx::atomic_write_json(&path, &report) {
|
||||
tracing::warn!(%error, path = %path.display(), "Could not save database schema report");
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
|
||||
fn describe_signature(signature: &str) -> String {
|
||||
let fields: Vec<_> = signature.split('|').collect();
|
||||
if fields.len() != 4 { return signature.into(); }
|
||||
let decode = |value: &str| -> String {
|
||||
if value == "<NULL>" { return "no default".into(); }
|
||||
if value == "<NONE>" { return "none".into(); }
|
||||
hex::decode(value).ok().and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.map(|text| format!("{text:?}")).unwrap_or_else(|| value.into())
|
||||
};
|
||||
format!("type={}, nullable={}, default={}, extra={}", fields[0], fields[1], decode(fields[2]), decode(fields[3]))
|
||||
}
|
||||
|
||||
fn read_columns(db: &Db, kind: &str) -> Result<BTreeMap<String, BTreeMap<String, String>>> {
|
||||
let schema = db.realm_schema(crate::db::schema_of(kind)?);
|
||||
let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'<NULL>',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','<NONE>',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?;
|
||||
@@ -98,7 +126,7 @@ pub fn check(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
||||
for (column, want) in cols {
|
||||
let found = actual[table].get(column);
|
||||
if found.is_none() || (!want.is_empty() && found != Some(want)) {
|
||||
problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {want}, found {s}")).unwrap_or_else(|| "Missing column".into()) });
|
||||
problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {}, found {}", describe_signature(want), describe_signature(s))).unwrap_or_else(|| "Missing column".into()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +175,14 @@ fn missing_coa_starts(rows: &str) -> Vec<Problem> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_defaults_are_readable_and_not_conflated() {
|
||||
assert_eq!(describe_signature("int unsigned|NO|30|<NONE>"), "type=int unsigned, nullable=NO, default=\"0\", extra=none");
|
||||
assert!(describe_signature("int unsigned|NO|<NULL>|<NONE>").contains("default=no default"));
|
||||
assert!(describe_signature("varchar(8)|YES||<NONE>").contains("default=\"\""));
|
||||
assert_eq!(describe_signature("legacy"), "legacy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_cannot_use_a_partial_or_empty_schema_contract() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -900,7 +900,7 @@ impl Env for RepackEnv<'_> {
|
||||
let mut modes = vec![crate::realms::Mode::Coa];
|
||||
if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); }
|
||||
for mode in modes {
|
||||
if let Some(problem) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() {
|
||||
if let Some(problem) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() {
|
||||
return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail)));
|
||||
}
|
||||
}
|
||||
@@ -968,13 +968,13 @@ impl Env for RepackEnv<'_> {
|
||||
result.failed = extra.failed;
|
||||
if result.snapshot.is_none() { result.snapshot = extra.snapshot; }
|
||||
if result.failed.is_none() {
|
||||
if let Some(p) = crate::schema_check::check(&other_db, root)?.first() {
|
||||
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() {
|
||||
let problems = crate::schema_check::check(db, root)?;
|
||||
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())));
|
||||
}
|
||||
@@ -995,7 +995,7 @@ impl Env for RepackEnv<'_> {
|
||||
let mut modes = vec![crate::realms::Mode::Coa];
|
||||
if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); }
|
||||
for mode in modes {
|
||||
if let Some(p) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() {
|
||||
if let Some(p) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() {
|
||||
return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.database, p.table, p.detail)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ def main():
|
||||
parser.add_argument('--mysql-bin', required=True, type=Path)
|
||||
parser.add_argument('--core', required=True, type=Path)
|
||||
parser.add_argument('--before', required=True)
|
||||
parser.add_argument('--display-repair', type=Path)
|
||||
args = parser.parse_args()
|
||||
suffix = '.exe' if (args.mysql_bin / 'mysqld.exe').exists() else ''
|
||||
server = args.mysql_bin / ('mysqld' + suffix)
|
||||
@@ -66,6 +67,21 @@ def main():
|
||||
effects = sql('SELECT effect_d0,effect_d1,effect_d2,effect_d3 FROM coa_boss_schedule WHERE entry=12264 AND idx=6;', schema).stdout.strip()
|
||||
assert effects == '2105613\t2105614\t2105615\t2105616', effects
|
||||
print('PASS: original SQL reproduces ERROR 1136 after migration; fixed SQL supports fresh, repeated and old-layout initialization')
|
||||
if args.display_repair:
|
||||
repair = args.display_repair.read_text()
|
||||
sql('CREATE DATABASE display_fixture;')
|
||||
sql(repair, 'display_fixture') # An absent table is not created by a default repair.
|
||||
sql('CREATE TABLE creature_display_preset (entry INT UNSIGNED NOT NULL, display_id INT UNSIGNED NOT NULL, PRIMARY KEY(entry,display_id)); INSERT INTO creature_display_preset VALUES(42,123);', 'display_fixture')
|
||||
before_rows = sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout
|
||||
sql(repair, 'display_fixture')
|
||||
sql(repair, 'display_fixture')
|
||||
default = sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip()
|
||||
assert default == '0', default
|
||||
assert sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout == before_rows
|
||||
sql('ALTER TABLE creature_display_preset ALTER COLUMN display_id SET DEFAULT 99;', 'display_fixture')
|
||||
sql(repair, 'display_fixture')
|
||||
assert sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip() == '99'
|
||||
print('PASS: display default repair is repeatable, keeps rows and custom defaults, and tolerates absent tables')
|
||||
finally:
|
||||
subprocess.run([str(admin), *connection, 'shutdown'], capture_output=True, creationflags=flags)
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user