Files
felhom-controller/controller/internal/backup/volume_dumps_test.go
T

194 lines
7.1 KiB
Go

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) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (f *volDumpFakeProvider) RecreateStackFromUnit(string, string, map[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
}