68b3a3932e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
120 lines
4.6 KiB
Go
120 lines
4.6 KiB
Go
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 …/<vol>.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)")
|
|
}
|
|
}
|