D22: every restore puts the data back exactly on failure and after a power cut (R-1177, R-1195); whole restore asks the unit first (R-1178); no mirror during a restore (R-1180); W22 restore refusals in Hungarian; unreleased
gates / gates (push) Successful in 1m40s

This commit is contained in:
2026-10-21 21:19:46 +02:00
parent 7848d4aaac
commit d582feb245
25 changed files with 1065 additions and 71 deletions
@@ -3,6 +3,7 @@ package backup
import (
"context"
"errors"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"log"
"path/filepath"
@@ -194,12 +195,10 @@ func TestR638_FallbackVolumeFailureSkipsTheReplay(t *testing.T) {
// means the database's volume may still be the LIVE (possibly newer, migrated) one, or a half-filled
// one — the copy overlays it and leaves whatever it does not know about. So the importer must not run.
//
// What must NOT change from today's failure path: the error reaches the caller as "completed with data
// errors" (no silent success), the definition is the unit's, and the app is brought back up.
//
// COMPANION RED-PROOF: on the pre-fix restore_unit.go (dataErr set, then fall through to the replay)
// this fails on `the importer ran after a failed volume leg` — saved in
// audits/design-build-2026-10-06/B/red-slice2-no-replay-after-volume-failure.txt.
// Since D22 (2026-10-21, R-1177) the rest of the failure path changed on purpose: a volume that moved with no copy
// to pour back (this fixture's unit carries no archive, yet the replay reports one volume replaced) is the half
// state, so the app is HELD — no definition write, no start. With a copy the live volumes go back and the app
// starts (TestR1177_*).
func TestR638_UnitVolumeFailureNeverCallsTheImporter(t *testing.T) {
m, prov, imported := r47UnitFixture(t, immichLikeCompose, true)
m.volumeReplayFrom = func(string, string) (int, error) {
@@ -213,8 +212,8 @@ func TestR638_UnitVolumeFailureNeverCallsTheImporter(t *testing.T) {
if err == nil {
t.Fatal("a failed volume leg must be surfaced, not reported as success")
}
if !strings.Contains(err.Error(), "completed with data errors") || !strings.Contains(err.Error(), "app_immich_postgres_data") {
t.Fatalf("the pre-existing outcome must be preserved and name the failed volume, got: %v", err)
if want := util.MsgError("err.backup.restore_failed_held_mixed", "app").Error(); err.Error() != want {
t.Fatalf("a volume that moved with no copy must HOLD the app, got: %v", err)
}
if res.VolumesReplayed != 1 {
t.Errorf("the partial volume count must still be reported, got %d", res.VolumesReplayed)
@@ -222,14 +221,11 @@ func TestR638_UnitVolumeFailureNeverCallsTheImporter(t *testing.T) {
if res.DBsReplayed != 0 {
t.Errorf("no database was replayed, but the result says %d", res.DBsReplayed)
}
if prov.gotEnv == nil {
t.Error("the unit's definition must still be written, as on today's failure path")
if prov.fullStarted {
t.Fatal("the app was started on a half-restored volume")
}
if !prov.fullStarted {
t.Fatal("the app was left down — today's failure path brings it back up")
}
if got := strings.Join(prov.calls, ","); got != "stop,recreate,start" {
t.Fatalf("sequence = %q, want stop → recreate → full start (no DB-only window, no replay)", got)
if got := strings.Join(prov.calls, ","); got != "stop,stop" {
t.Fatalf("sequence = %q, want stop → (hold) stop: no definition write, no DB-only window, no start", got)
}
}