R-1253 (P2): an export that stops its app refuses while another operation (a restore) holds the app-stop marker — its Undo is never replaced. Commit only.
gates / gates (push) Successful in 1m28s

This commit is contained in:
2026-10-24 20:29:59 +02:00
parent 518a13592d
commit c9b4b82dc7
9 changed files with 178 additions and 1 deletions
+4 -1
View File
@@ -3601,9 +3601,12 @@ func (a *exportAdapter) GetStacksBaseDir() string {
type exportStopGuard struct{ g *backup.AppStopGuard }
func (a exportStopGuard) Begin(opID string, stacks []string) error {
return a.g.Begin(opID, backup.ReasonAppExport, stacks)
// R-1253: never replace the marker another operation (a restore) holds — its Undo is the only crash record.
return a.g.BeginExclusive(opID, backup.ReasonAppExport, stacks)
}
func (a exportStopGuard) BusyWith() string { return a.g.BusyWith() }
func (a exportStopGuard) End() { a.g.End() }
func (a exportStopGuard) ReleaseFailed(stacks ...string) { a.g.ReleaseFailed(stacks...) }
@@ -0,0 +1,57 @@
package main
import (
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-1253 (review round 2, HUNT-1 H3): an export with „stop the app first" during another app's restore replaced the
// ONE app-stop marker — the restore's crash record and its Undo were lost, and a power cut then left the restored app
// on mixed data with no put-back. The test is the review's mechanism with two Begins on one guard, through the
// exporter's real adapter. RED-PROOF (2026-10-24): with exportStopGuard.Begin calling Begin instead of BeginExclusive,
// this fails on „the restore's Undo is gone from the marker".
func TestR1253_AnExportNeverReplacesARestoresMarker(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "appstop-state.json")
g := backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))
if err := g.Begin("unit-restore:bookstack", backup.ReasonUnitRestore, []string{"bookstack"}); err != nil {
t.Fatal(err)
}
undo := filepath.Join(dir, "backups", "restore-undo", "bookstack", "20261024T200000Z")
if err := g.SetUndo("bookstack", undo); err != nil {
t.Fatal(err)
}
ex := exportStopGuard{g: g}
if busy := ex.BusyWith(); busy != "unit-restore:bookstack" {
t.Fatalf("the export must see the restore holding the marker, got %q", busy)
}
err := ex.Begin("app-export:immich", []string{"immich"})
if !errors.Is(err, backup.ErrAppStopBusy) {
t.Fatalf("the export's Begin must be refused while the restore holds the marker, got %v", err)
}
b, _ := os.ReadFile(marker)
if !strings.Contains(string(b), undo) || strings.Contains(string(b), "immich") {
t.Fatalf("the restore's Undo is gone from the marker (or the export replaced it): %s", b)
}
// the restore ends → the export may run
g.End()
if err := ex.Begin("app-export:immich", []string{"immich"}); err != nil {
t.Fatalf("after the restore ended the export must start: %v", err)
}
ex.End()
// a marker a CRASH left (a new process over the same file) does not block: nothing in this process will End it
if err := g.Begin("volume-dump:paperless", backup.ReasonVolumeDump, []string{"paperless"}); err != nil {
t.Fatal(err)
}
fresh := exportStopGuard{g: backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))}
if busy := fresh.BusyWith(); busy != "" {
t.Fatalf("a crash-left marker is not a live operation, got %q", busy)
}
}