cf48214f6c
gates / gates (push) Successful in 9s
R-167: new internal/fillwatch warns the CUSTOMER before a filesystem fills. It emits the PRE-EXISTING disk_warning/disk_critical pair, which was allowlisted, copy'd, default-enabled and checkbox'd with no producer in any repo — the sixth "built but never wired" instance here. Two threshold terms (85% or 5 GiB free; critical 95%/2 GiB) because a percentage alone lies at both ends of this fleet's size range. Edge-triggered on escalation only, state persisted, hysteresis dead zone at 75%/7 GiB pinned by a test. A nil usage read is never a warning and never clears one. Per filesystem, never per app. Daily 03:30, before the nightly app-data legs. R-158: new unitNotify seam fires per app when a Tier-1 recovery-unit capture fails, loop continuing, carrying the target filesystem's used/free bytes. Operator-tier (recovery_unit_capture_failed) — deliberately NOT backup_failed, which is customer-enabled and would email the customer about a failure they cannot act on. D-c overrides R-158's own proposal here. R-174: the app-stop guard no longer starts apps onto MISSING drives — a regression in v0.189.0 code, found by review and closed the same session. SetStarter got the raw stack manager, whose StartStack has no drive gate, and Recover runs at startup. R-171 one path over. bootDriveGate could not be reused whole (its holder #2 is the guard's own marker, and holders #1/#2 read vars assigned after Recover runs), so holder #3 is extracted into a shared driveStartGate with a test pinning the delegation. ErrStartRefused splits a refusal from a failure: both keep the marker, only Failed alarms, because routing a deliberate hold into NotifyBackupFailed is the same false alarm. Tests 1157 -> 1184. All red-proofs demonstrated failing and restored.
183 lines
7.2 KiB
Go
183 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|