package backup import ( "io" "log" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // R-158 / R-167 (D-c, operator half) — a per-app Tier-1 recovery-unit capture failure must reach a // hub channel. // // THE GAP THESE CLOSE. `captureAllRecoveryUnits` logged `[WARN] Recovery unit capture failed for %s` // and stopped there. The manager carried three notify seams — tier2Notify, offboxNotify, // offboxEnlargeBlockedNotify — and none for the unit capture, so the one page a person opens to ask // whether ONE app is backed up (`/backups/apps`) was the one page that never said. Fifth instance in // this project of a mechanism built and left disconnected. // unitFailProvider lists a fixed set of stacks and refuses GetStackRecoveryInfo for the named ones, // which is the earliest real failure inside CaptureRecoveryUnit ("stack %q not found"). type unitFailProvider struct { stacks []string fail map[string]bool dir string } func (p *unitFailProvider) GetStackComposePath(string) (string, bool) { return "", false } func (p *unitFailProvider) ListDeployedStacks() []StackSummary { out := make([]StackSummary, 0, len(p.stacks)) for _, s := range p.stacks { out = append(out, StackSummary{Name: s}) } return out } func (p *unitFailProvider) GetStackHDDMounts(string) []string { return nil } func (p *unitFailProvider) GetStackHDDPath(string) string { return "" } func (p *unitFailProvider) GetImportRoot() string { return "" } func (p *unitFailProvider) GetDockerVolumes(string) []string { return nil } func (p *unitFailProvider) StopStack(string) error { return nil } func (p *unitFailProvider) StartStack(string) error { return nil } func (p *unitFailProvider) RefreshAndIsRunning(string) bool { return true } func (p *unitFailProvider) GetStackRecoveryInfo(name string) (RecoveryInfo, bool) { if p.fail[name] { return RecoveryInfo{}, false } return RecoveryInfo{StackDir: filepath.Join(p.dir, "stacks", name)}, true } func (p *unitFailProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *unitFailProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error { return nil } func (p *unitFailProvider) StartStackServices(string, []string) error { return nil } func (p *unitFailProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return nil, false } var _ appbackup.StackDataProvider = (*unitFailProvider)(nil) type unitEvent struct { app string err string usage *UnitSpace } func newUnitNotifyManager(t *testing.T, stacks []string, fail map[string]bool) (*Manager, *[]unitEvent) { t.Helper() dir := t.TempDir() m := &Manager{ logger: log.New(io.Discard, "", 0), systemDataPath: dir, stackProvider: &unitFailProvider{stacks: stacks, fail: fail, dir: dir}, } var got []unitEvent m.SetUnitNotify(func(name string, err error, usage *UnitSpace) { got = append(got, unitEvent{app: name, err: err.Error(), usage: usage}) }) return m, &got } // --- Scenario C — a local unit capture failure reaches the operator ------------------------------ func TestCaptureAll_FailureNotifiesOnceWithTheSpaceFigures(t *testing.T) { m, got := newUnitNotifyManager(t, []string{"immich"}, map[string]bool{"immich": true}) m.captureAllRecoveryUnits() if len(*got) != 1 { t.Fatalf("got %d unit-failure events, want exactly 1 — a per-app Tier-1 capture failure "+ "reached no hub channel, which is the R-158 gap un-fixed", len(*got)) } e := (*got)[0] if e.app != "immich" { t.Fatalf("event names app %q, want immich — an operator cannot act on an unnamed app", e.app) } if e.err == "" { t.Fatal("the event carries no error — the operator is told a capture failed but not why") } // The space figures are the point: the overwhelmingly likely cause is a full filesystem, and // these answer "why" without an operator logging in. if e.usage == nil { t.Fatal("the event carries no space figures for a readable target filesystem — this is the " + "pair of numbers that makes the alert actionable, and the same pair the customer fill " + "warning reports (which is why the two ship together)") } if e.usage.Path == "" || e.usage.TotalGB <= 0 { t.Fatalf("space figures are not populated: %+v", e.usage) } } // --- Scenario D — one failing app does not silence the others ------------------------------------ func TestCaptureAll_OneFailureDoesNotAbortOrDuplicate(t *testing.T) { m, got := newUnitNotifyManager(t, []string{"homebox", "immich", "nextcloud"}, map[string]bool{"immich": true}) m.captureAllRecoveryUnits() if len(*got) != 1 { t.Fatalf("got %d events, want exactly 1 — either the loop ABORTED on the middle app "+ "(and its siblings were never captured), or one failure produced several events: %+v", len(*got), *got) } if (*got)[0].app != "immich" { t.Fatalf("event names %q, want immich", (*got)[0].app) } // The siblings must have been ATTEMPTED after the failure — a positive observable, not the // absence of an event. The provider records nothing, so assert via the failure set instead: // flip the LAST app to failing and require both events. m2, got2 := newUnitNotifyManager(t, []string{"homebox", "immich", "nextcloud"}, map[string]bool{"immich": true, "nextcloud": true}) m2.captureAllRecoveryUnits() if len(*got2) != 2 { t.Fatalf("got %d events, want 2 — the app AFTER the first failure was never reached, so the "+ "loop is aborting rather than continuing: %+v", len(*got2), *got2) } if (*got2)[0].app != "immich" || (*got2)[1].app != "nextcloud" { t.Fatalf("events %+v, want immich then nextcloud in loop order", *got2) } } // A successful capture must be SILENT. An alert that fires on success is an alert an operator learns // to ignore. func TestCaptureAll_SuccessIsSilent(t *testing.T) { m, got := newUnitNotifyManager(t, []string{"homebox"}, nil) m.captureAllRecoveryUnits() if len(*got) != 0 { t.Fatalf("a successful capture fired %d event(s): %+v", len(*got), *got) } } // The seam must be nil-safe: an unwired notify is the pre-v0.191.0 behaviour (a WARN line), never a // panic that takes the whole nightly backup down with it. func TestCaptureAll_UnwiredNotifyDoesNotPanic(t *testing.T) { dir := t.TempDir() m := &Manager{ logger: log.New(io.Discard, "", 0), systemDataPath: dir, stackProvider: &unitFailProvider{stacks: []string{"immich"}, fail: map[string]bool{"immich": true}, dir: dir}, } m.captureAllRecoveryUnits() // no SetUnitNotify — must not panic } // §8.4 in the failure direction: an unreadable target filesystem is reported as UNKNOWN, never as // zeros. "0 GB free" and "we could not look" are opposite diagnoses, and rendering the second as the // first is the presence-is-not-success trap pointing the other way. func TestUnitSpace_NilRendersAsUnavailableNotZero(t *testing.T) { var u *UnitSpace s := u.String() if !strings.Contains(s, "unavailable") { t.Fatalf("nil UnitSpace renders as %q — it must say the reading is unavailable", s) } if strings.Contains(s, "0.0") { t.Fatalf("nil UnitSpace renders zeros (%q) — an operator would read \"the disk is full\" "+ "from a filesystem nobody could read", s) } }