R-361: the safety dump destroyed the app's own database backup
gates / gates (push) Successful in 11s
gates / gates (push) Successful in 11s
writeSafetyDump called DumpOne into the app's OWN unit dir and renamed the result to pre-restore-* afterwards. DumpOne writes <stack>-<dbtype>.sql - the app's canonical dump - so every safety dump overwrote the app's real backup and then moved it away, leaving the app with no database backup until the next nightly run. A local restore-from-unit in that window tells the customer the app never had a database. The comment beside it asserted the rename meant it 'can never overwrite the app's real dump'. False as written, and believed for four months. Measured live before the fix: docmost and bookstack each held only pre-restore-* files and no canonical dump. DumpOneTo takes the final path and derives its own .tmp from it. DumpOne keeps its signature and calls it with the canonical name. writeSafetyDump asks for its own name directly; the rename is gone; the comment now states the invariant and how it is enforced. db_dumps no longer lists the undo copies. All three consumers of Manifest.DBDumps were grepped and named - all inside recovery_unit.go, none reads it for recovery. The files are neither deleted nor hidden. Tests 1485 -> 1493. FIVE red-proofs, TWO PASSED first time and both are reported: the behavioural tests inject the dump seam so a mutation inside DumpOneTo was invisible, and 1.3 had no test at all. Guards added at the layer each defect lives in; both mutations then convicted.
This commit is contained in:
@@ -183,18 +183,25 @@ func (m *Manager) writeSafetyDump(ctx context.Context, stackName, nsRoot string)
|
||||
}
|
||||
set := safetyDumpSet{Stamp: time.Now().UTC().Format("20060102T150405Z")}
|
||||
for _, db := range mine {
|
||||
res := m.dumpForSafety(ctx, db, dumpDir)
|
||||
// R-361: the undo copy is dumped STRAIGHT to its own name. It used to be dumped to the app's
|
||||
// canonical `<stack>-<dbtype>.sql` and renamed afterwards, and the comment here asserted that
|
||||
// the rename meant it "can never overwrite the app's real dump". THAT WAS FALSE AS WRITTEN:
|
||||
// `DumpOne` writes the canonical name, so every safety dump destroyed the app's own backup and
|
||||
// then moved it away — leaving the app with NO database backup until the next nightly run, and
|
||||
// a local restore-from-unit in that window telling the customer the app never had a database.
|
||||
// Measured live on demo-hp 2026-08-22: `docmost` and `bookstack` both held only `pre-restore-*`
|
||||
// files and no canonical dump.
|
||||
//
|
||||
// THE INVARIANT, AND HOW IT IS NOW ENFORCED: nothing but the app's own dump is ever written to
|
||||
// the canonical name, because the safety dump never names it — `DumpOneTo` takes the final path
|
||||
// and derives its own `.tmp` from it, so neither the destination nor the scratch file can
|
||||
// collide with a nightly dump running beside it. Pinned by
|
||||
// TestR361_SafetyDumpLeavesTheCanonicalDumpByteIdentical.
|
||||
safe := filepath.Join(dumpDir, fmt.Sprintf("%s%s-%s-%s.sql", preRestoreDumpPrefix, set.Stamp, stackName, db.DBType))
|
||||
res := m.dumpForSafety(ctx, db, safe)
|
||||
if res.Error != nil {
|
||||
return safetyDumpSet{}, fmt.Errorf("a jelenlegi adatbázis biztonsági mentése sikertelen (%s): %w — a visszaállítás nem indult el", db.ContainerName, res.Error)
|
||||
}
|
||||
// DumpOne writes `<stack>-<dbtype>.sql`; rename it under the safety prefix so it can never be
|
||||
// picked up as a replay SOURCE and can never overwrite the app's real dump.
|
||||
safe := filepath.Join(dumpDir, fmt.Sprintf("%s%s-%s-%s.sql", preRestoreDumpPrefix, set.Stamp, stackName, db.DBType))
|
||||
if res.FilePath != safe {
|
||||
if err := os.Rename(res.FilePath, safe); err != nil {
|
||||
return safetyDumpSet{}, fmt.Errorf("a biztonsági mentés véglegesítése sikertelen: %w", err)
|
||||
}
|
||||
}
|
||||
// EVERY file, not just the first — R-379, and the reason is on safetyDumpSet.
|
||||
set.Files = append(set.Files, safetyDumpFile{DB: db, Path: safe})
|
||||
m.logger.Printf("[INFO] [offbox] %s: pre-restore safety dump written → %s (%s)", stackName, filepath.Base(safe), humanizeBytes(res.Size))
|
||||
@@ -403,12 +410,15 @@ func (m *Manager) rollbackSafetyDump(ctx context.Context, stack string, set safe
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpForSafety is the DumpOne seam for the safety dump (tests inject; nil → the real DumpOne).
|
||||
func (m *Manager) dumpForSafety(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult {
|
||||
// dumpForSafety is the dump seam for the safety dump (tests inject; nil → the real DumpOneTo).
|
||||
//
|
||||
// R-361: it takes the FINAL PATH, not a directory. A directory argument is what allowed the callee to
|
||||
// choose the canonical name, which is the whole defect.
|
||||
func (m *Manager) dumpForSafety(ctx context.Context, db DiscoveredDB, finalPath string) DumpResult {
|
||||
if m.safetyDumpFn != nil {
|
||||
return m.safetyDumpFn(ctx, db, dumpDir)
|
||||
return m.safetyDumpFn(ctx, db, finalPath)
|
||||
}
|
||||
return DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
|
||||
return DumpOneTo(ctx, db, finalPath, m.logger, m.isDebug())
|
||||
}
|
||||
|
||||
// ReconstituteFromOffsite makes the live app equal to a restored full-scratch snapshot: files
|
||||
|
||||
Reference in New Issue
Block a user