Files
felhom-controller/controller/internal/backup/volume_dumps_test.go
T
admin 2958946517 v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).

Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.

Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.

Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.

One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.

Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.

Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.

Tests 915 -> 949, all green. MinAgent unchanged.
2026-07-26 08:12:57 +02:00

199 lines
7.4 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) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
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
}