package backup import ( "io" "log" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // volDumpFakeProvider is a StackDataProvider for the runVolumeDumps gating tests: a configurable // stack list with per-stack volumes + drive, and a StopStack recorder (the destructive act the // gates must prevent for volume-less/protected stacks). type volDumpFakeProvider struct { stacks []StackSummary volumes map[string][]string hdd map[string]string stopped []string } func (f *volDumpFakeProvider) GetStackComposePath(string) (string, bool) { return "", false } func (f *volDumpFakeProvider) ListDeployedStacks() []StackSummary { return f.stacks } func (f *volDumpFakeProvider) GetStackHDDMounts(string) []string { return nil } func (f *volDumpFakeProvider) GetStackHDDPath(name string) string { return f.hdd[name] } func (f *volDumpFakeProvider) GetDockerVolumes(name string) []string { return f.volumes[name] } func (f *volDumpFakeProvider) StopStack(name string) error { f.stopped = append(f.stopped, name) return nil } func (f *volDumpFakeProvider) StartStack(string) error { return nil } func (f *volDumpFakeProvider) RefreshAndIsRunning(string) bool { return true } func (f *volDumpFakeProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false } func (f *volDumpFakeProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) { return nil, false } func (f *volDumpFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (f *volDumpFakeProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error { return nil } func (f *volDumpFakeProvider) StartStackServices(string, []string) error { return nil } // TestRunVolumeDumps_GatesPrecedeDump proves Scenario D/E's gating: the dump is invoked ONLY for // a volume-bearing, unprotected stack on a writable drive. The negatives are the point — // volume-less (rallly-like), protected (traefik-like), and disconnected-drive stacks are never // dumped (and therefore never stopped, since stopping happens inside DumpAppVolumesSafe). // COMPANION red-proof: removing the volume gate makes the seam fire for "rallly" → this fails. func TestRunVolumeDumps_GatesPrecedeDump(t *testing.T) { tmp := t.TempDir() usbDrive := filepath.Join(tmp, "usb") badDrive := filepath.Join(tmp, "gone") sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: badDrive, Label: "gone"}); err != nil { t.Fatal(err) } if err := sett.SetDisconnected(badDrive, true, nil); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.SystemDataPath = filepath.Join(tmp, "sys") cfg.Stacks.Protected = []string{"traefik"} fake := &volDumpFakeProvider{ stacks: []StackSummary{ {Name: "nextcloud"}, {Name: "rallly"}, {Name: "traefik"}, {Name: "diskapp"}, }, volumes: map[string][]string{ "nextcloud": {"nextcloud_nextcloud_html"}, "rallly": nil, // volume-less — must NOT be stopped/dumped "traefik": {"traefik_data"}, // protected — never considered "diskapp": {"diskapp_data"}, // volume-bearing but drive disconnected }, hdd: map[string]string{"nextcloud": usbDrive, "diskapp": badDrive}, } m := &Manager{cfg: cfg, settings: sett, logger: log.New(io.Discard, "", 0), systemDataPath: cfg.Paths.SystemDataPath, stackProvider: fake} var dumpCalls []string m.dumpVolumesSafe = func(name string) error { dumpCalls = append(dumpCalls, name) return nil } summary, dumped, ok := m.runVolumeDumps() if !ok { t.Fatalf("run should be ok, summary=%v", summary) } if len(dumpCalls) != 1 || dumpCalls[0] != "nextcloud" { t.Errorf("dump invoked for %v, want exactly [nextcloud] (gates must exclude volume-less/protected/disconnected)", dumpCalls) } if dumped != 1 { t.Errorf("dumped = %d, want 1", dumped) } if len(fake.stopped) != 0 { t.Errorf("StopStack called for %v — the seam bypasses the real dump, so ANY stop means a gate leaked", fake.stopped) } // The disconnected drive appears as a SKIP in the summary (same style as the DB loop). if !containsSummary(summary, "SKIP diskapp volumes (drive disconnected)") { t.Errorf("summary missing disconnected SKIP entry: %v", summary) } } // TestRunVolumeDumps_VolumelessNeverStopped drives the REAL DumpAppVolumesSafe path (no seam) with // only volume-less/protected stacks: the volume gate must keep them from ever being stopped. This // is the direct Scenario D negative — without the gate, DumpAppVolumesSafe stops the stack BEFORE // its own volume check, so this test fails with stopped=[rallly]. func TestRunVolumeDumps_VolumelessNeverStopped(t *testing.T) { cfg := &config.Config{} cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys") cfg.Stacks.Protected = []string{"traefik"} fake := &volDumpFakeProvider{ stacks: []StackSummary{{Name: "rallly"}, {Name: "traefik"}}, volumes: map[string][]string{"traefik": {"traefik_data"}}, } m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), systemDataPath: cfg.Paths.SystemDataPath, stackProvider: fake} // deliberately NO seam: the real DumpAppVolumesSafe would record StopStack on the fake. if _, dumped, ok := m.runVolumeDumps(); !ok || dumped != 0 { t.Fatalf("expected clean zero-dump run, dumped=%d ok=%v", dumped, ok) } if len(fake.stopped) != 0 { t.Errorf("volume-less/protected stacks were stopped: %v", fake.stopped) } } // TestRunVolumeDumps_FailureSurfaces proves no-silent-partial: a per-stack dump failure lands in // the summary as a FAIL entry, flips allOK, and does NOT abort the remaining stacks. func TestRunVolumeDumps_FailureSurfaces(t *testing.T) { cfg := &config.Config{} cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys") fake := &volDumpFakeProvider{ stacks: []StackSummary{{Name: "broken"}, {Name: "healthy"}}, volumes: map[string][]string{ "broken": {"broken_data"}, "healthy": {"healthy_data"}, }, } m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), systemDataPath: cfg.Paths.SystemDataPath, stackProvider: fake} m.dumpVolumesSafe = func(name string) error { if name == "broken" { return errTest } return nil } summary, dumped, ok := m.runVolumeDumps() if ok { t.Error("allOK must be false after a dump failure") } if dumped != 1 { t.Errorf("the healthy stack must still be dumped after another's failure (dumped=%d)", dumped) } if !containsSummaryPrefix(summary, "FAIL broken volumes:") { t.Errorf("summary missing FAIL entry: %v", summary) } // failedSummaryLines feeds the run's returned error — the FAIL entry must survive the filter. if failed := failedSummaryLines(summary); len(failed) != 1 { t.Errorf("failedSummaryLines = %v, want exactly the broken entry", failed) } } var errTest = &testErr{} type testErr struct{} func (*testErr) Error() string { return "tar exploded" } func containsSummary(summary []string, want string) bool { for _, s := range summary { if s == want { return true } } return false } func containsSummaryPrefix(summary []string, prefix string) bool { for _, s := range summary { if strings.HasPrefix(s, prefix) { return true } } return false }