0cfcc42464
PlaceOffsiteRestore: live target via raw GetStackHDDPath not AppNamespaceRoot (F-3a-1a: no SSD merge; undeployed refused), placement headroom gate (F-3a-1b), stat pre-pass over all placements before any copy (F-3a-4: no partial writes), scratch removed on success/kept on failure (F-3a-2). mapOffsiteRestorePaths refuses the namespace root itself (F-3a-3). Delivery chain: DefaultEnabledEvents + GetNotificationPrefs append-if-absent migration + settings checkbox + handler slice; paired with hub v0.55.0 allowlist (no customerMessages entry — raw dynamic message survives). +8 tests; all 6 controller §10 red-proofs verified.
139 lines
5.3 KiB
Go
139 lines
5.3 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// placeFixture builds a manager + provider with a scratch dir for stack on drive, a snapshot whose
|
|
// paths anchor on `oldNs`, and (per `full`) the reconstructed scratch srcs on disk. Returns the copier
|
|
// invocation counter pointer and the scratch dir. Free/size seams default to "plenty of room".
|
|
func placeFixture(t *testing.T, full bool) (*Manager, *offbox3aProvider, string, *int) {
|
|
t.Helper()
|
|
drive := t.TempDir()
|
|
m, _, prov := classifiedOffboxManager(t, drive)
|
|
prov.hdd["immich"] = drive
|
|
|
|
scratch, liveNs, err := m.offboxRestoreScratchDir("immich")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Snapshot paths anchored on a synthetic POSIX namespace (drive-churn realistic; also avoids the
|
|
// Windows volume-letter that filepath.Join can't nest — prod paths are Linux, no volume).
|
|
oldNs := "/felhomdata/ns"
|
|
unitP := oldNs + "/backups/primary/immich"
|
|
dataP := oldNs + "/appdata/immich"
|
|
snapPaths := []string{unitP, dataP}
|
|
|
|
// Create the reconstructed scratch srcs the code will stat — computed via the pure mapper so the
|
|
// fixture matches the code's own path arithmetic (no hand-predicting OS separators).
|
|
placements, err := mapOffsiteRestorePaths(snapPaths, "immich", scratch, liveNs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pl := range placements {
|
|
if !full && !pl.isUnit {
|
|
continue // unit-only scratch: userdata src deliberately absent (Scenario C)
|
|
}
|
|
if err := os.MkdirAll(pl.src, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
|
|
m.SetOffboxSizer(func(string) int64 { return 1 << 20 })
|
|
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
if contains(args, "snapshots") {
|
|
return []byte(`[{"short_id":"a","time":"2026-07-15T00:00:00Z","paths":["` + unitP + `","` + dataP + `"]}]`), nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
var copies int
|
|
m.SetOffboxPlaceCopier(func(_, _ string) (int, error) { copies++; return 1, nil })
|
|
return m, prov, scratch, &copies
|
|
}
|
|
|
|
// A (F-3a-1a): undeployed placement refused with ZERO copies (never merges onto the SSD namespace).
|
|
func TestPlace_UndeployedRefused(t *testing.T) {
|
|
m, prov, scratch, copies := placeFixture(t, true)
|
|
prov.hdd["immich"] = "" // undeployed
|
|
err := m.PlaceOffsiteRestore(context.Background(), "immich")
|
|
if err == nil || !strings.Contains(err.Error(), "nincs telepítve") {
|
|
t.Fatalf("undeployed must refuse with 'nincs telepítve', got %v", err)
|
|
}
|
|
if *copies != 0 {
|
|
t.Errorf("copier must NOT run for an undeployed app, got %d", *copies)
|
|
}
|
|
if _, sErr := os.Stat(scratch); sErr != nil {
|
|
t.Error("scratch must be untouched on refusal")
|
|
}
|
|
}
|
|
|
|
// B (F-3a-1b): placement headroom gate refuses BEFORE any copy.
|
|
func TestPlace_HeadroomRefused(t *testing.T) {
|
|
m, _, _, copies := placeFixture(t, true)
|
|
m.SetOffboxFreeFn(func(string) int64 { return 1 }) // 1 byte free
|
|
m.SetOffboxSizer(func(string) int64 { return 1 << 30 })
|
|
err := m.PlaceOffsiteRestore(context.Background(), "immich")
|
|
if err == nil || !strings.Contains(err.Error(), "Nincs elég szabad hely") {
|
|
t.Fatalf("headroom gate must refuse, got %v", err)
|
|
}
|
|
if *copies != 0 {
|
|
t.Errorf("copier must NOT run when headroom fails, got %d", *copies)
|
|
}
|
|
}
|
|
|
|
// C (F-3a-4): a unit-only scratch (userdata src absent) refuses with ZERO copies (stat pre-pass).
|
|
func TestPlace_IncompleteScratchRefusedNoCopies(t *testing.T) {
|
|
m, _, _, copies := placeFixture(t, false) // full=false → userdata src missing
|
|
err := m.PlaceOffsiteRestore(context.Background(), "immich")
|
|
if err == nil || !strings.Contains(err.Error(), "hiányos") {
|
|
t.Fatalf("incomplete scratch must refuse with 'hiányos', got %v", err)
|
|
}
|
|
if *copies != 0 {
|
|
t.Errorf("stat pre-pass must refuse BEFORE any copy, got %d copies", *copies)
|
|
}
|
|
}
|
|
|
|
// E (F-3a-2): success removes the scratch (ready-gate flips false); failure keeps it.
|
|
func TestPlace_ScratchLifecycle(t *testing.T) {
|
|
// success
|
|
m, _, scratch, copies := placeFixture(t, true)
|
|
if err := m.PlaceOffsiteRestore(context.Background(), "immich"); err != nil {
|
|
t.Fatalf("placement: %v", err)
|
|
}
|
|
if *copies == 0 {
|
|
t.Error("expected at least one copy on success")
|
|
}
|
|
if _, sErr := os.Stat(scratch); !os.IsNotExist(sErr) {
|
|
t.Errorf("scratch must be removed after success, stat err=%v", sErr)
|
|
}
|
|
if m.OffboxFullScratchReady("immich") {
|
|
t.Error("OffboxFullScratchReady must be false after cleanup")
|
|
}
|
|
|
|
// failure keeps the scratch
|
|
m2, _, scratch2, _ := placeFixture(t, true)
|
|
m2.SetOffboxPlaceCopier(func(_, _ string) (int, error) { return 0, os.ErrPermission })
|
|
if err := m2.PlaceOffsiteRestore(context.Background(), "immich"); err == nil {
|
|
t.Fatal("a copier failure must surface as an error")
|
|
}
|
|
if _, sErr := os.Stat(scratch2); sErr != nil {
|
|
t.Errorf("scratch must be KEPT after a failed placement (retry), stat err=%v", sErr)
|
|
}
|
|
}
|
|
|
|
// D (F-3a-3): mapping refuses the namespace root itself among the snapshot paths.
|
|
func TestMapOffsiteRestorePaths_RefusesNamespaceRoot(t *testing.T) {
|
|
oldNs := "/old/ns"
|
|
snap := []string{oldNs + "/backups/primary/app", oldNs} // oldNs itself must be refused
|
|
if _, err := mapOffsiteRestorePaths(snap, "app", "/scratch", "/new/ns"); err == nil {
|
|
t.Error("the namespace root itself among snapshot paths must be refused (F-3a-3)")
|
|
}
|
|
}
|