2958946517
${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.
199 lines
7.4 KiB
Go
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
|
|
}
|