package backup import ( "io" "log" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // atomicTestManager wires a Manager whose GetAppDrivePath resolves to a single drive dir, with the // F7 tar seam injected. Returns the manager + the volume-dump dir where `.tar`/`.tar.tmp` land. func atomicTestManager(t *testing.T, stack, volName string, tar func(volName, dumpDir string) ([]byte, error)) (*Manager, string) { t.Helper() drive := t.TempDir() cfg := &config.Config{} cfg.Paths.SystemDataPath = drive fake := &volDumpFakeProvider{ stacks: []StackSummary{{Name: stack}}, volumes: map[string][]string{stack: {volName}}, } m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), systemDataPath: drive, stackProvider: fake, tarVolume: tar} dumpDir := AppVolumeDumpPath(m.namespaceRoot(drive), stack) return m, dumpDir } // Happy path: the tar seam writes a good `.tar.tmp`; DumpAppVolumes promotes it to `.tar`, leaves no // `.tmp`, and the content is exactly what the seam wrote. func TestDumpAppVolumes_HappyAtomicPromote(t *testing.T) { const vol = "app_data" want := []byte("GOOD-TAR-CONTENT") m, dumpDir := atomicTestManager(t, "app", vol, func(_, dir string) ([]byte, error) { return nil, os.WriteFile(filepath.Join(dir, vol+".tar.tmp"), want, 0644) }) if err := m.DumpAppVolumes("app"); err != nil { t.Fatalf("DumpAppVolumes: %v", err) } got, err := os.ReadFile(filepath.Join(dumpDir, vol+".tar")) if err != nil { t.Fatalf("final .tar missing: %v", err) } if string(got) != string(want) { t.Errorf("final .tar content = %q, want %q", got, want) } if _, err := os.Stat(filepath.Join(dumpDir, vol+".tar.tmp")); !os.IsNotExist(err) { t.Errorf("a `.tar.tmp` was left behind after a successful dump") } } // THE F7 RED-PROOF: a good `.tar` already exists (the last restore point). The tar seam fails // mid-write (writes a partial/0-byte `.tar.tmp` then errors, simulating a NFS cut). The ORIGINAL // `.tar` MUST be byte-identical afterwards, and no 0-byte `.tar` may exist. Companion: revert // DumpAppVolumes to the in-place `tar cf …/.tar` write → the marker is truncated → this fails. func TestDumpAppVolumes_MidWriteFailurePreservesLastGood(t *testing.T) { const vol = "app_data" marker := []byte("LAST-GOOD-247-BYTE-DUMP-MARKER") // the campaign's 247-byte last-good dump, in spirit m, dumpDir := atomicTestManager(t, "app", vol, func(_, dir string) ([]byte, error) { // Simulate a mid-write cut: partially write the tmp, then fail (as a dead NFS target would). _ = os.WriteFile(filepath.Join(dir, vol+".tar.tmp"), []byte("PARTIAL-TRUNCATED"), 0644) return []byte("tar: write error: Input/output error"), errTest }) // Pre-seed the last good restore point. if err := os.MkdirAll(dumpDir, 0755); err != nil { t.Fatal(err) } tarPath := filepath.Join(dumpDir, vol+".tar") if err := os.WriteFile(tarPath, marker, 0644); err != nil { t.Fatal(err) } err := m.DumpAppVolumes("app") if err == nil { t.Fatal("DumpAppVolumes must report failure when the tar step fails") } // The restore point is byte-identical — F7's whole point. got, rerr := os.ReadFile(tarPath) if rerr != nil { t.Fatalf("the last good `.tar` was DESTROYED by a failed write (F7 regression): %v", rerr) } if string(got) != string(marker) { t.Errorf("last good `.tar` was mutated by a failed write: got %q, want %q (F7 regression)", got, marker) } // No stray tmp and no 0-byte tar. if _, err := os.Stat(filepath.Join(dumpDir, vol+".tar.tmp")); !os.IsNotExist(err) { t.Errorf("the failed `.tar.tmp` was not cleaned up") } if info, _ := os.Stat(tarPath); info != nil && info.Size() == 0 { t.Errorf("the restore point is now a 0-byte file (the exact F7 failure)") } } // A leftover `.tar.tmp` from a previously-killed run is cleaned up by the next dump and is never a // restore point (its name ends `.tmp`, invisible to the `.tar` restore-point scan). func TestDumpAppVolumes_LeftoverTmpCleaned(t *testing.T) { const vol = "app_data" m, dumpDir := atomicTestManager(t, "app", vol, func(_, dir string) ([]byte, error) { return nil, os.WriteFile(filepath.Join(dir, vol+".tar.tmp"), []byte("new"), 0644) }) if err := os.MkdirAll(dumpDir, 0755); err != nil { t.Fatal(err) } // A stale tmp for a DIFFERENT (removed) volume — must be swept. stale := filepath.Join(dumpDir, "old_removed_vol.tar.tmp") if err := os.WriteFile(stale, []byte("stale"), 0644); err != nil { t.Fatal(err) } if err := m.DumpAppVolumes("app"); err != nil { t.Fatalf("DumpAppVolumes: %v", err) } if _, err := os.Stat(stale); !os.IsNotExist(err) { t.Errorf("a stale `.tar.tmp` was left behind (must be swept)") } }