08eb1a6e3a
gates / gates (push) Failing after 12s
ReconstituteFromOffsite and PlaceOffsiteRestore both resolved the restore destination with the RAW HDD_PATH and read an empty answer as "the app is not installed". For 40 of the 53 catalogue apps that answer is correctly empty and permanent, so both actions refused forever for a running, healthy app — and told the customer to reinstall it "in the same place", which those apps never offer. Separate the two questions. "Installed?" is asked of ListDeployedStacks via a new Manager.isStackDeployed that fails CLOSED on a nil provider. "Where?" is answered by GetAppDrivePath — the same resolver CaptureRecoveryUnit wrote the snapshot with, so the restore aims at the place the backup came from. The 13 drive apps are unchanged: own drive, mismatch check, ack still required. A third refusal, with its own sentence, covers installed-but-no-resolvable-root. Fixtures that marked an app "installed" by giving it an HDD path now state deployment as its own fact. No assertion weakened.
146 lines
5.8 KiB
Go
146 lines
5.8 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
|
|
// R-356: "installed" is now asked directly (ListDeployedStacks), not inferred from owning a
|
|
// drive. The fixture must say so — before, the HDD path alone stood in for both facts, which is
|
|
// exactly the conflation this change removes.
|
|
prov.deployed = map[string]bool{"immich": true}
|
|
|
|
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)
|
|
// R-356: undeployed means ABSENT FROM ListDeployedStacks. Clearing only the HDD path no longer
|
|
// makes an app look uninstalled — a driveless app is the normal case for 40 of the 53 apps.
|
|
prov.deployed = map[string]bool{}
|
|
prov.hdd["immich"] = ""
|
|
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)")
|
|
}
|
|
}
|