v0.218.0: attribute a DB container by its compose project, and replay volumes on the off-site restore
gates / gates (push) Successful in 11s
gates / gates (push) Successful in 11s
R-355 (first, because it is the only one where data can be lost for good). paperless-ngx's PostgreSQL was dumped into backups/primary/paperless/db-dumps/ — a directory for a stack that does not exist, on the system drive — while the app's own unit recorded db_dumps: null. The same misattribution reached writeSafetyDump, so a destructive restore of that app took NO undo copy and the fail-closed refusal was never reached. Fixed by reading the compose project label, which is the stack name by construction (compose runs with cmd.Dir set to the stack dir and no -p). The old derivation stays as the fallback and an unresolvable attribution is now loud. Catalogue sweep, proven able to convict: one affected app of 53. The fix is in the controller, not the catalogue. R-354. ReconstituteFromOffsite skipped every unit placement and the volume archives live inside the unit, so the off-site restore had no volume leg at all — proven live with planted files: calibre-web's 1,422,848-byte config archive was in the unit, the snapshot and the checking folder, and the restore reported success without it. For the 40 of 53 apps that declare no data drive that archive is the whole dataset. restoreDockerVolumesFrom is the local path's own replay with an explicit directory: ONE implementation, two callers. Volumes replay before the database and inside the stopped window. VolumesReplayed reaches the message. The comment beside the skip was half false and is corrected; the half that still holds — the live unit is the local path's source — is named, and scenario D fingerprints the whole live unit across the operation. Seven red-proofs, each asserted applied and reverted. Two found defects in the tests, not the code: scenario D passed with the unit guard removed because the fingerprint had been narrowed and was blind to the unit root.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -467,12 +468,38 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
|
||||
if !res.DumpsAt.IsZero() {
|
||||
when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
|
||||
}
|
||||
// R-354 — WHAT ACTUALLY CAME BACK, NAMED. The volume leg is stated whenever it returned anything,
|
||||
// because a restore that replayed an app's entire dataset and mentioned only its file count is
|
||||
// precisely how a silent loss reads as a success: on 2026-08-21 calibre-web was told
|
||||
// „5 fájl visszaállítva" over a run that had dropped a 1 422 848-byte volume archive. Every clause
|
||||
// here is conditional on having done the thing, so a snapshot with no volumes produces the exact
|
||||
// sentence it produced before (pinned by TestReconstituteOutcome_NoVolumesWordingUnchanged).
|
||||
what := fmt.Sprintf("%d fájl", res.FilesPlaced)
|
||||
if res.VolumesReplayed > 0 {
|
||||
what += fmt.Sprintf(" és %d adatkötet", res.VolumesReplayed)
|
||||
}
|
||||
if res.DBsReplayed > 0 {
|
||||
what += " és az adatbázis"
|
||||
}
|
||||
msg := fmt.Sprintf("A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", app, what, when)
|
||||
|
||||
if res.DBsReplayed == 0 {
|
||||
// A no-database app: saying "és az adatbázis" here would be a lie, and this is precisely the
|
||||
// class of sentence the DIAG found being printed over a no-op.
|
||||
return fmt.Sprintf("A(z) %s: %d fájl visszaállítva%s — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa.", app, res.FilesPlaced, when)
|
||||
//
|
||||
// R-355: „nincs adatbázisa" is a claim ABOUT THE APP and must not be inferred from a counter.
|
||||
// `DBsReplayed == 0` has two causes — the app has no database, or it has one and the snapshot
|
||||
// carried no dump for it — and until now both printed the same confident sentence. On
|
||||
// 2026-08-21 that sentence was shown over a live 72-table PostgreSQL the controller had dumped
|
||||
// five minutes earlier. `SafetyDump` is the honest discriminator: writeSafetyDump returns a
|
||||
// path only when a live database for this app was found AND successfully dumped, so a non-empty
|
||||
// value proves the app HAS one. Same counter, two different facts, and now two sentences.
|
||||
if res.SafetyDump != "" {
|
||||
return msg + fmt.Sprintf(" FIGYELEM: ennek az alkalmazásnak VAN adatbázisa, de a mentés nem tartalmazott adatbázis-mentést, ezért az adatbázis NEM állt vissza. A visszaállítás előtti állapot mentése megvan: %s", filepath.Base(res.SafetyDump))
|
||||
}
|
||||
return msg + " Ennek az alkalmazásnak nincs adatbázisa."
|
||||
}
|
||||
return fmt.Sprintf("A(z) %s: %d fájl és az adatbázis visszaállítva%s — az alkalmazás újraindult.", app, res.FilesPlaced, when)
|
||||
return msg
|
||||
}
|
||||
|
||||
// offboxVerifyCopyDeleteHandler removes ONE verification copy (v0.147.0, 4a).
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-355, the sentence. „Ennek az alkalmazásnak nincs adatbázisa" is a claim ABOUT THE APP, and it was
|
||||
// being inferred from a counter that has two different causes. On 2026-08-21 it was printed over a live
|
||||
// 72-table PostgreSQL that the controller had dumped five minutes earlier.
|
||||
//
|
||||
// `SafetyDump` is the honest discriminator: writeSafetyDump returns a path only when a live database
|
||||
// for this app was found AND successfully dumped.
|
||||
|
||||
func TestReconstituteOutcome_NoDatabaseOnlyWhenThereIsNone(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 3, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
if !strings.Contains(msg, "nincs adatbázisa") {
|
||||
t.Errorf("an app with genuinely no database should still say so; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// The one that matters: a database exists, none was replayed. Saying "this app has no database" here
|
||||
// is false, and saying nothing at all would leave a silent loss under a success.
|
||||
func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 0, DBsReplayed: 0,
|
||||
SafetyDump: "/mnt/x/db-dumps/pre-restore-20260822T060000Z-paperless-ngx-postgres.sql",
|
||||
})
|
||||
if strings.Contains(msg, "nincs adatbázisa") {
|
||||
t.Fatalf("FALSE CLAIM: told the customer the app has no database while its undo copy proves it does; got %q", msg)
|
||||
}
|
||||
for _, want := range []string{"VAN adatbázisa", "NEM állt vissza"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("the message must state that a database exists and did not come back; missing %q in %q", want, msg)
|
||||
}
|
||||
}
|
||||
// The undo copy is the customer's way back, so it has to be named.
|
||||
if !strings.Contains(msg, "pre-restore-20260822T060000Z-paperless-ngx-postgres.sql") {
|
||||
t.Errorf("the message must name the undo copy; got %q", msg)
|
||||
}
|
||||
// It must never leak the directory — only the file name.
|
||||
if strings.Contains(msg, "/mnt/x/") {
|
||||
t.Errorf("the message leaked a filesystem path; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 4, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
|
||||
})
|
||||
if !strings.Contains(msg, "és az adatbázis visszaállítva") {
|
||||
t.Errorf("the full case must keep its wording; got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "FIGYELEM") {
|
||||
t.Errorf("a complete restore must not carry a warning; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-354: the volume leg has to reach the sentence ─────────────────────────────────────────────
|
||||
|
||||
// The 2026-08-21 case, as the customer saw it and as they must see it now.
|
||||
func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("calibre-web", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 5, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
if !strings.Contains(msg, "5 fájl és 1 adatkötet visszaállítva") {
|
||||
t.Errorf("the message must name the volume that came back; got %q", msg)
|
||||
}
|
||||
// The old sentence — five files and nothing else — must be gone.
|
||||
if strings.Contains(msg, "5 fájl visszaállítva") {
|
||||
t.Errorf("the pre-fix wording is still being produced; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// A volume-only app: the whole dataset is the volume, and "0 fájl" alone said nothing about it.
|
||||
func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("privatebin", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 0, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
if !strings.Contains(msg, "0 fájl és 1 adatkötet visszaállítva") {
|
||||
t.Errorf("a volume-only restore must state the volume; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C: a snapshot with no volume archives must produce the EXACT sentence it produced before,
|
||||
// so the change cannot be read as "a volume was expected and did not arrive".
|
||||
func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) {
|
||||
noDB := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 3, VolumesReplayed: 0, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
if noDB != "A(z) opengist: 3 fájl visszaállítva — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa." {
|
||||
t.Errorf("the no-volume, no-database wording changed; got %q", noDB)
|
||||
}
|
||||
withDB := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 4, VolumesReplayed: 0, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
|
||||
})
|
||||
if withDB != "A(z) romm: 4 fájl és az adatbázis visszaállítva — az alkalmazás újraindult." {
|
||||
t.Errorf("the no-volume, with-database wording changed; got %q", withDB)
|
||||
}
|
||||
if strings.Contains(noDB, "adatkötet") || strings.Contains(withDB, "adatkötet") {
|
||||
t.Error("a restore that replayed no volume must not mention volumes at all")
|
||||
}
|
||||
}
|
||||
|
||||
// All three legs at once.
|
||||
func TestReconstituteOutcome_AllThreeLegs(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 12, VolumesReplayed: 3, DBsReplayed: 1, SafetyDump: "/x/pre-restore-p.sql",
|
||||
})
|
||||
want := "A(z) paperless-ngx: 12 fájl és 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."
|
||||
if msg != want {
|
||||
t.Errorf("got %q\nwant %q", msg, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user